From afd2c6c9ec5d1a88db092a50b872855adbf2a3c0 Mon Sep 17 00:00:00 2001 From: Viktor Koves <3187531+vkoves@users.noreply.github.com> Date: Tue, 27 Aug 2024 21:29:46 -0500 Subject: [PATCH] Add tooltip to spark line This lets us actually investigate the underlying values --- src/components/SparkLine.vue | 128 +++++++++++++++++++++++++----- src/components/StatTile.vue | 2 +- src/templates/BuildingDetails.vue | 15 ++-- 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/components/SparkLine.vue b/src/components/SparkLine.vue index ff45fe62..73c48a1c 100644 --- a/src/components/SparkLine.vue +++ b/src/components/SparkLine.vue @@ -1,6 +1,9 @@ @@ -26,6 +29,9 @@ export default class BarGraph extends Vue { /** A unit to append to the min and max values (e.g. "tons") */ @Prop({required: true}) unit?: string; + readonly svgIdPrefix = 'spark-svg-'; + readonly svgContPrefix = 'spark-cont-'; + /* Strip HTML from the unit (just for CO2) and simplify by dropping 'metric' */ get unitCleaned(): string { if (!this.unit) { return ''; } @@ -50,11 +56,13 @@ export default class BarGraph extends Vue { // The amount to shift the x-axis down by readonly xAxisOffset = 60; - readonly graphMargins = { top: 50, right: 0, bottom: 110, left: 0 }; + readonly graphMargins = { top: 50, right: 15, bottom: 110, left: 15 }; readonly barMargin = 0.2; randomId = Math.round(Math.random() * 1000); + tooltip?: d3.Selection; + svg!: d3.Selection; mounted(): void { @@ -62,7 +70,7 @@ export default class BarGraph extends Vue { const outerHeight = this.height + this.graphMargins.top + this.graphMargins.bottom; this.svg = d3 - .select("svg#spark" + this.randomId) + .select(`svg#${this.svgIdPrefix}${this.randomId}`) .attr("width", outerWidth) .attr("height", outerHeight) .attr("viewBox", `0 0 ${outerWidth} ${outerHeight}`) @@ -71,6 +79,7 @@ export default class BarGraph extends Vue { .attr("transform", `translate(${this.graphMargins.left},${this.graphMargins.top})`); this.calculateMinAndMaxPoints(); + this.setupTooltip(); this.renderGraph(); } @@ -96,7 +105,6 @@ export default class BarGraph extends Vue { this.minAndMaxPoints = minAndMaxPoints; } - renderGraph(): void { // Empty the SVG this.svg.html(null); @@ -156,28 +164,29 @@ export default class BarGraph extends Vue { const LabelFontSize = 24; if (this.minAndMaxPoints) { - // Add the min and max points + // Add all points, we'll then use CSS to hide all but the min and max unless hovered this.svg .append("g") .selectAll("dot") - .data(this.minAndMaxPoints) + .data(this.graphData) .enter() .append("circle") - .attr("cx", (d) => { - // If the point is the first year, move it right by the radius to align it to the left - // edge of the graph, reverse for the last year - if (d.x === minYear) { - return x(d.x) + DotRadius; - } - else if (d.x === maxYear) { - return x(d.x) - DotRadius; - } + .attr('class', (d) => { + const isMinMax = d.x === this.minAndMaxPoints![0].x + || d.x === this.minAndMaxPoints![1].x; - return x(d.x); // don't adjust any dots in the middle + return isMinMax ? 'dot -min-max' : 'dot'; }) + .attr("cx", (d) => x(d.x)) .attr("cy", (d) => y(d.y)) .attr("r", DotRadius) - .attr("fill", "black"); + .attr("fill", "black") + .attr('tabindex', '0') + .on("mouseover", this.mouseover.bind(this)) + .on("focusin", (event: Event, d) => this.focus(event, d)) + .on("blur", this.mouseleave.bind(this)) + .on("mousemove", (event: MouseEvent, d) => this.mousemove(event, d)) + .on("mouseleave", this.mouseleave.bind(this)); // Add the value labels for the min and max points this.svg @@ -232,22 +241,105 @@ export default class BarGraph extends Vue { .style("font-size", LabelFontSize); } } + + /** Create the empty tooltip element we fill later */ + setupTooltip(): void { + // create a tooltip + this.tooltip = d3.select(`#${this.svgContPrefix}${this.randomId}`) + .append("div") + .style("opacity", 0) + .attr("class", "tooltip"); + } + + focus(event: Event, datum: INumGraphPoint): void { + this.mouseover(); + this.mousemove(event as MouseEvent, datum); + } + + mouseover(): void { + this.tooltip?.style("opacity", 1); + } + + mousemove(event: Event, datum: INumGraphPoint): void { + // Calculate a scale factor to match the internal SVG space to the rendered HTML space + const outerWidth = this.width + this.graphMargins.left + this.graphMargins.right; + const svgElem = document.getElementById(`${this.svgIdPrefix}${this.randomId}`); + const scaleFactor = svgElem!.clientWidth / outerWidth; + + const tooltipX = d3.pointer(event)[0] * scaleFactor + 20; + const tooltipY = d3.pointer(event)[1] * scaleFactor; + + this.tooltip! + .html( + `
${datum.x}
` + + `
` + + `${datum.y.toLocaleString()} ` + + `${this.unit}` + + `
`, + ) + .style("left", `${tooltipX}px`) + .style("top", `${tooltipY}px`); + } + + mouseleave(datum: INumGraphPoint): void { + this.tooltip?.style("opacity", 0); + } }