rtimelog 0.51.0

System for tracking time in a text-log-based format.
Documentation
//! Represent the construction of an SVG histogram.

use std::io::Write;

use xml::writer::{EventWriter, XmlEvent};

use crate::chart::colors::ColorMap;
use crate::chart::day::Hour;
use crate::chart::{utils, DayHours, Percentages};
use crate::emit_xml;
use crate::Result;
use crate::TaskEvent;

// Styles for the bar graph
const STYLESHEET: &str = r#"
text {
    font-size: 12;
    text-anchor: middle;
}
"#;

const BAR_HEIGHT: f32 = 60.0;
const CHART_HEIGHT: f32 = BAR_HEIGHT + 20.0;

/// Configuration for drawing an hourly graph showing projects worked during
/// that hour.
pub struct BarGraph {
    bar_width: usize,
    colors:    ColorMap
}

impl BarGraph {
    /// Create a [`BarGraph`] from the supplied percentages.
    pub fn new(percents: &Percentages) -> Self {
        Self { bar_width: 19, colors: ColorMap::new(percents) }
    }

    // Displayed width of a bar
    fn bar_width(&self) -> usize { self.bar_width }

    // Distance between left edge of adjacent bars
    fn hour_width(&self) -> usize { self.bar_width + 1 }

    // Draw a single bar for the supplied [`Hour`].
    fn write_hour<W: Write>(
        &self, w: &mut EventWriter<W>, begin: usize, hr: &Hour, i: usize
    ) -> Result<()> {
        let id = format!("hr{:02}", i + begin);
        let xform = format!("translate({}, {})", i * self.hour_width(), BAR_HEIGHT);
        emit_xml!(w, g, id: &id, transform: &xform => {
            let mut offset = 0.0;
            for task in hr.iter() {
                offset = self.write_task(w, task, offset)?;
            }
            emit_xml!(w, text, x: "10", y: "13"; &format!("{}", begin + i))
        })
    }

    // Draw the block for a single [`TaskEvent`] on the current hour.
    //
    // # Errors
    //
    // Could return any formatting error
    fn write_task<W: Write>(
        &self, w: &mut EventWriter<W>, task: &TaskEvent, offset: f32
    ) -> Result<f32> {
        #![allow(clippy::cast_precision_loss)]
        let height = task.as_secs() as f32 / BAR_HEIGHT;
        let offset = offset - height;
        let ht_str = utils::format_coord(height);
        let off_str = utils::format_coord(offset);
        let bar = format!("{}", self.bar_width());
        emit_xml!(w, rect, x: "0", y: &off_str, height: &ht_str, width: &bar,
            fill: self.colors.get(&task.project()).unwrap_or("black")  // black will only happen if something goes wrong
        )?;
        Ok(offset)
    }

    /// Write an SVG representation of the [`DayHours`] as a bar graph.
    ///
    /// # Errors
    ///
    /// Could return any formatting error
    pub fn write<W: Write>(&self, w: &mut EventWriter<W>, day_hours: &DayHours) -> Result<()> {
        let width = format!("{}", day_hours.num_hours() * self.hour_width());
        let view = format!("0 0 {width} {CHART_HEIGHT}");
        emit_xml!(w, svg, viewbox: &view, width: &width, height: &format!("{CHART_HEIGHT}") => {
            emit_xml!(w, style; STYLESHEET)?;
            let path = format!("M0,{} h{}", BAR_HEIGHT, day_hours.num_hours() * self.hour_width() - 1);
            emit_xml!(w, path, d: &path, stroke: "black")?;

            let begin = day_hours.start();
            for (i, hr) in day_hours.iter().enumerate() {
                self.write_hour(w, begin, hr, i)?;
            }
            Ok(())
        })
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use spectral::prelude::*;
    use xml::EmitterConfig;

    use super::*;
    use crate::chart::{DayHours, TagPercent};
    use crate::date::DateTime;

    fn percentages() -> Percentages {
        [
            TagPercent::new("david", 40.0).unwrap(),
            TagPercent::new("connie", 30.0).unwrap(),
            TagPercent::new("mark", 20.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap()
        ]
        .iter()
        .cloned()
        .collect()
    }

    fn make_task(proj: &str, time: (u32, u32, u32), secs: u64) -> TaskEvent {
        TaskEvent::new(
            DateTime::new((2022, 3, 21), time).unwrap(),
            proj,
            Duration::from_secs(secs)
        )
    }

    fn day() -> DayHours {
        let mut day_hrs = DayHours::default();

        for ev in [
            make_task("david", (9, 0, 0), 300),
            make_task("kirsten", (9, 5, 0), 3600),
            make_task("mark", (10, 5, 0), 3300),
            make_task("connie", (11, 0, 0), 1800),
            make_task("kirsten", (11, 30, 0), 1800),
            make_task("mark", (13, 0, 0), 3600),
            make_task("connie", (14, 0, 0), 1800)
        ]
        .into_iter()
        {
            day_hrs.add(ev);
        }

        day_hrs
    }

    #[test]
    fn test_new() {
        let percents = percentages();

        let bg = BarGraph::new(&percents);

        assert_that!(bg.hour_width()).is_equal_to(&20);
        assert_that!(bg.bar_width()).is_equal_to(&19);
    }

    #[test]
    fn test_write_histogram() {
        let percents = percentages();
        let bg = BarGraph::new(&percents);
        let day_hrs = day();

        let mut output: Vec<u8> = Vec::new();
        let mut w = EmitterConfig::new()
            .perform_indent(true)
            .write_document_declaration(false)
            .create_writer(&mut output);
        assert_that!(bg.write(&mut w, &day_hrs)).is_ok();
        let actual = String::from_utf8(output).unwrap();
        let expected = r##"<svg viewbox="0 0 120 80" width="120" height="80">
  <style>
text {
    font-size: 12;
    text-anchor: middle;
}
</style>
  <path d="M0,60 h119" stroke="black" />
  <g id="hr09" transform="translate(0, 60)">
    <rect x="0" y="-5" height="5" width="19" fill="#1f78b4" />
    <rect x="0" y="-60" height="55" width="19" fill="#b2df8a" />
    <text x="10" y="13">9</text>
  </g>
  <g id="hr10" transform="translate(20, 60)">
    <rect x="0" y="-5" height="5" width="19" fill="#b2df8a" />
    <rect x="0" y="-60" height="55" width="19" fill="#33a02c" />
    <text x="10" y="13">10</text>
  </g>
  <g id="hr11" transform="translate(40, 60)">
    <rect x="0" y="0" height="0" width="19" fill="#33a02c" />
    <rect x="0" y="-30" height="30" width="19" fill="#a6cee3" />
    <rect x="0" y="-60" height="30" width="19" fill="#b2df8a" />
    <text x="10" y="13">11</text>
  </g>
  <g id="hr12" transform="translate(60, 60)">
    <rect x="0" y="0" height="0" width="19" fill="#b2df8a" />
    <text x="10" y="13">12</text>
  </g>
  <g id="hr13" transform="translate(80, 60)">
    <rect x="0" y="-60" height="60" width="19" fill="#33a02c" />
    <text x="10" y="13">13</text>
  </g>
  <g id="hr14" transform="translate(100, 60)">
    <rect x="0" y="0" height="0" width="19" fill="#33a02c" />
    <rect x="0" y="-30" height="30" width="19" fill="#a6cee3" />
    <text x="10" y="13">14</text>
  </g>
</svg>"##;
        assert_that!(actual.as_str()).is_equal_to(expected);
    }
}