aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesse Morgan <jesse@jesterpm.net>2026-01-25 17:28:28 -0800
committerJesse Morgan <jesse@jesterpm.net>2026-01-25 17:28:28 -0800
commit5cf9a5e311d163b30a6cf22f5cc980a09c34e2a0 (patch)
treea83101e9e52469d1eff321ec4378994f98fe2fea
Initial commitHEADmaster
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock420
-rw-r--r--Cargo.toml11
-rw-r--r--LICENSE19
-rw-r--r--README.md65
-rw-r--r--src/bin/tc-calmap.rs110
-rw-r--r--src/heatmap.rs203
-rw-r--r--src/lib.rs14
8 files changed, 844 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..890bfb5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+*.sw[lmnop]
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..ba301c3
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,420 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bumpalo"
+version = "3.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+
+[[package]]
+name = "cc"
+version = "1.2.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.180"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "timechart"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "clap",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..ee08ffe
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "timechart"
+version = "0.1.0"
+description = "Tools to visualize time series data"
+repository = "https://git.jesterpm.net/pub/jesterpm/timechart.git"
+license = "MIT"
+edition = "2021"
+
+[dependencies]
+chrono = "0.4"
+clap = { version = "4.4.14", features = ["derive"] }
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..78e4f84
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright © 2026 Jesse Morgan <jesse@jesterpm.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the “Software”),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e93a31d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+timechart
+=========
+
+A collection of tools to visualize time series data. Each tool takes pairs
+of dates and values in stdin and produces an SVG on stdout.
+
+Example input:
+
+ 2026-01-05 4.7
+ 2026-01-07 6.5
+ 2026-01-09 7.5
+ 2026-01-12 10.0
+ 2026-01-14 6.8
+ 2026-01-16 6.0
+ 2026-01-20 7.7
+ 2026-01-22 6.4
+
+tc-calmap
+---------
+
+The calmap tool produces a calendar-like heatmap, where each square
+represents a date and the color represents the value.
+
+There are two different rendering modes: calendar and iso. The calendar
+mode produces a graphic that looks like a normal month-by-month calendar.
+The iso mode produces a denser grid where each row is the day of the week
+and each column is an ISO week.
+
+
+ Usage: tc-calmap [OPTIONS]
+
+ Options:
+ --size <SIZE>
+ The size of each square in the heat map (in px)
+
+ [default: 10]
+
+ --padding <PADDING>
+ The padding between the squares (in px)
+
+ [default: 2]
+
+ --colors <COLORS>
+ The color scale
+
+ [default: #e7ebef #deebf7 #c6dbef #9ecae1 #6baed6 #2171b5]
+
+ --unit <UNIT>
+ The unit description to use in the captions
+
+ --log-scale
+ Use a log scale instead of a linear scale
+
+ --mode <MODE>
+ [default: calendar]
+ [possible values: calendar, iso]
+
+ -h, --help
+ Print help (see a summary with '-h')
+
+
+Contributing
+------------
+
+Send questions, bug reports, and patches to jesse@jesterpm.net.
diff --git a/src/bin/tc-calmap.rs b/src/bin/tc-calmap.rs
new file mode 100644
index 0000000..566815a
--- /dev/null
+++ b/src/bin/tc-calmap.rs
@@ -0,0 +1,110 @@
+use std::io::{Write, stderr, stdin, stdout};
+use std::process::exit;
+
+use chrono::{Datelike, NaiveDate};
+use clap::{Parser, ValueEnum};
+
+use timechart::heatmap::{CalendarProjection, Heatmap, IsoProjection, Projection};
+use timechart::{Data, Result};
+
+/// Render a calendar-like heatmap from a list of dates and values.
+///
+/// Values are read from stdin. Output is written to stdout.
+#[derive(Parser, Debug)]
+struct Args {
+ /// The size of each square in the heat map (in px)
+ #[arg(long, default_value = "10")]
+ size: u32,
+
+ /// The padding between the squares (in px)
+ #[arg(long, default_value = "2")]
+ padding: u32,
+
+ /// The color scale
+ #[arg(
+ long,
+ default_values = ["#e7ebef","#deebf7","#c6dbef","#9ecae1","#6baed6","#2171b5"]
+ )]
+ colors: Vec<String>,
+
+ /// The unit description to use in the captions
+ #[arg(long)]
+ unit: Option<String>,
+
+ /// Use a log scale instead of a linear scale
+ #[arg(long, default_value = "true")]
+ log_scale: bool,
+
+ #[arg(
+ long("mode"),
+ value_enum,
+ default_value_t = Mode::Calendar
+ )]
+ mode: Mode,
+}
+
+#[derive(Debug, Clone, ValueEnum)]
+enum Mode {
+ Calendar,
+ Iso,
+}
+
+fn main() {
+ match run() {
+ Ok(_) => {}
+ Err(e) => {
+ let _ = writeln!(stderr(), "Error: {e}");
+ exit(1);
+ }
+ }
+}
+
+fn run() -> Result<()> {
+ let args = Args::parse();
+
+ // Read data from stdin.
+ let mut data = Data::new();
+ for line in stdin().lines() {
+ if let Some((ts, value)) = line?.split_once(' ') {
+ let date: NaiveDate = ts.parse()?;
+ let value: f64 = value.parse()?;
+ data.entry(date)
+ .and_modify(|v| *v += value)
+ .or_insert(value);
+ }
+ }
+
+ // Find the range of dates that we will render.
+ let (start, end) = data
+ .keys()
+ .copied()
+ .fold((NaiveDate::MAX, NaiveDate::MIN), |acc, v| {
+ let min = if acc.0 < v {
+ acc.0
+ } else {
+ NaiveDate::from_ymd_opt(v.year(), 1, 1).unwrap()
+ };
+ let max = if acc.1 > v {
+ acc.1
+ } else {
+ NaiveDate::from_ymd_opt(v.year(), 12, 31).unwrap()
+ };
+ (min, max)
+ });
+
+ // Choose a drawing style.
+ let projection: Box<dyn Projection> = match args.mode {
+ Mode::Calendar => Box::new(CalendarProjection::new(start, end, args.size, args.padding)),
+ Mode::Iso => Box::new(IsoProjection::new(start, end, args.size, args.padding)),
+ };
+
+ // Render the heatmap.
+ let heatmap = Heatmap {
+ projection,
+ colors: args.colors,
+ unit: args.unit,
+ log_scale: args.log_scale,
+ };
+
+ heatmap.render(&data, stdout())
+}
diff --git a/src/heatmap.rs b/src/heatmap.rs
new file mode 100644
index 0000000..dd3ac3b
--- /dev/null
+++ b/src/heatmap.rs
@@ -0,0 +1,203 @@
+use std::io::Write;
+
+use chrono::{Datelike, NaiveDate};
+
+use crate::{Data, Result};
+
+/// Renders an SVG heatmap.
+pub struct Heatmap {
+ pub projection: Box<dyn Projection>,
+ pub colors: Vec<String>,
+ pub unit: Option<String>,
+ pub log_scale: bool,
+}
+
+impl Heatmap {
+ pub fn render<T: Write>(&self, data: &Data, mut io: T) -> Result<()> {
+ // No data, no image.
+ if data.is_empty() {
+ writeln!(
+ io,
+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>"#
+ )?;
+ return Ok(());
+ }
+
+ let (y0, y1) = data.values().copied().fold((f64::MAX, f64::MIN), |acc, v| {
+ let min = if acc.0 < v { acc.0 } else { v };
+ let max = if acc.1 > v { acc.1 } else { v };
+ (min, max)
+ });
+
+ // The color scale range.
+ let y_range = if self.log_scale {
+ y1.log10() - y0.log10()
+ } else {
+ y1 - y0
+ };
+
+ let (width, height) = self.projection.dimensions();
+ write!(
+ io,
+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">"#
+ )?;
+
+ let (start, end) = self.projection.range();
+ for date in start.iter_days() {
+ if date > end {
+ break;
+ }
+
+ let (x, y) = self.projection.map(date);
+ let value = data.get(&date).copied().unwrap_or(0.0);
+
+ // Scale
+ let scaled_value = if self.log_scale {
+ (value.signum() * (1.0 + value.abs()).log10())
+ / (y1.signum() * (1.0 + y1.abs()).log10())
+ } else {
+ (value + y0) / y_range
+ };
+
+ let color = &self.colors[(scaled_value * (self.colors.len() - 1) as f64) as usize];
+
+ writeln!(
+ io,
+ r#"<rect x="{x}" y="{y}" width="{0}" height="{0}" fill="{color}" rx="2" ry="2">"#,
+ self.projection.size()
+ )?;
+ if let Some(ref unit) = self.unit {
+ writeln!(io, "<title>{value} {unit} on {date}</title>")?;
+ } else {
+ writeln!(io, "<title>{value} {date}</title>")?;
+ }
+ writeln!(io, "</rect>")?;
+ }
+ writeln!(io, "</svg>")?;
+
+ Ok(())
+ }
+}
+
+/// Projection instances determine where each date goes in the image.
+pub trait Projection {
+ /// Transform a date to x,y coordinates.
+ fn map(&self, date: NaiveDate) -> (u32, u32);
+
+ /// Return the width and height of the image.
+ fn dimensions(&self) -> (u32, u32);
+
+ /// Return the size of each cell in the image.
+ fn size(&self) -> u32;
+
+ /// Return the range of dates in the image.
+ fn range(&self) -> (NaiveDate, NaiveDate);
+}
+
+/// CalendarProjection creates a heatmap that looks like a calendar, with all
+/// of the months in a row.
+pub struct CalendarProjection {
+ start: NaiveDate,
+ end: NaiveDate,
+ size: u32,
+ padding: u32,
+}
+
+impl CalendarProjection {
+ pub fn new(start: NaiveDate, end: NaiveDate, size: u32, padding: u32) -> Self {
+ Self {
+ start,
+ end,
+ size,
+ padding,
+ }
+ }
+}
+
+impl Projection for CalendarProjection {
+ fn map(&self, date: NaiveDate) -> (u32, u32) {
+ let fom = date.with_day(1).unwrap();
+ let year_number = (date.year() - self.start.year()) as u32;
+ let week_number = (fom.weekday().num_days_from_monday() + date.day() - 1) / 7 + 1;
+
+ let x = self.padding
+ + ((date.month() - self.start.month()) % 12)
+ * (self.padding + 7 * (self.size + self.padding))
+ + date.weekday().num_days_from_monday() * (self.size + self.padding);
+
+ let y = self.padding
+ + week_number * (self.size + self.padding)
+ + year_number * 6 * (self.size + self.padding);
+
+ (x, y)
+ }
+
+ fn dimensions(&self) -> (u32, u32) {
+ let years = (self.end.year() - self.start.year() + 1) as u32;
+ let width = self.padding + ((self.size + self.padding) * 8) * 12;
+ let height = self.padding + (self.size + self.padding) * 6 * years;
+ (width, height)
+ }
+
+ fn size(&self) -> u32 {
+ self.size
+ }
+
+ fn range(&self) -> (NaiveDate, NaiveDate) {
+ (self.start, self.end)
+ }
+}
+
+/// IsoProjection creates a denser heatmap where each column is a week of the
+/// ISO calendar year and each row is a day of the week (starting with Monday).
+pub struct IsoProjection {
+ start: NaiveDate,
+ end: NaiveDate,
+ size: u32,
+ padding: u32,
+}
+
+impl IsoProjection {
+ pub fn new(start: NaiveDate, end: NaiveDate, size: u32, padding: u32) -> Self {
+ Self {
+ start,
+ end,
+ size,
+ padding,
+ }
+ }
+}
+
+impl Projection for IsoProjection {
+ fn map(&self, date: NaiveDate) -> (u32, u32) {
+ let start = self.start.iso_week();
+ let iso_date = date.iso_week();
+
+ let year_number = (iso_date.year() - start.year()) as u32;
+
+ let x = self.padding + iso_date.week0() * (self.size + self.padding);
+
+ let y = self.padding
+ + date.weekday().num_days_from_monday() * (self.size + self.padding)
+ + year_number * 8 * (self.size + self.padding);
+
+ (x, y)
+ }
+
+ fn dimensions(&self) -> (u32, u32) {
+ let start = self.start.iso_week();
+ let end = self.end.iso_week();
+ let width = self.padding + (self.size + self.padding) * 53;
+ let height =
+ self.padding + (self.size + self.padding) * 8 * (end.year() - start.year() + 1) as u32;
+ (width, height)
+ }
+
+ fn size(&self) -> u32 {
+ self.size
+ }
+
+ fn range(&self) -> (NaiveDate, NaiveDate) {
+ (self.start, self.end)
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..d1ae1a9
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,14 @@
+use std::collections::BTreeMap;
+
+use chrono::NaiveDate;
+
+pub mod heatmap;
+
+/// The type used for the collection of date/value pairs.
+pub type Data = BTreeMap<NaiveDate, f64>;
+
+/// The Error type used within the library.
+pub type Error = Box<dyn std::error::Error>;
+
+/// The Result type used within the library.
+pub type Result<T> = std::result::Result<T, Error>;