From 4f5ae60f5e84605983fd2330450ea03f4cb7711f Mon Sep 17 00:00:00 2001 From: CodingPhoenixx Date: Fri, 13 Feb 2026 17:53:11 +0100 Subject: [PATCH] fixed line charts --- flightscore/src/components/line-chart.ts | 300 +++++++++++++++++++++-- flightscore/src/pages/dev-page.ts | 8 +- 2 files changed, 285 insertions(+), 23 deletions(-) diff --git a/flightscore/src/components/line-chart.ts b/flightscore/src/components/line-chart.ts index e97c070..980059b 100644 --- a/flightscore/src/components/line-chart.ts +++ b/flightscore/src/components/line-chart.ts @@ -1,5 +1,5 @@ -import { LitElement, html, css, svg } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, html, css, svg, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; export interface LineSeries { label: string; @@ -7,6 +7,22 @@ export interface LineSeries { points: { x: number; y: number }[]; } +interface SnapPoint { + label: string; + color: string; + x: number; + y: number; + screenX: number; + screenY: number; +} + +interface HoverState { + snapX: number; + pctX: number; + pctY: number; + points: SnapPoint[]; +} + @customElement('line-chart') export class LineChart extends LitElement { static styles = css` @@ -29,11 +45,7 @@ export class LineChart extends LitElement { } .chart-wrapper:hover { - border-color: color-mix( - in srgb, - var(--color-accent) 40%, - transparent - ); + border-color: color-mix(in srgb, var(--color-accent) 40%, transparent); } .header { @@ -62,6 +74,7 @@ export class LineChart extends LitElement { flex: 1 1 auto; aspect-ratio: 500 / 300; overflow: visible; + position: relative; } :host([style*='height']) .svg-container { @@ -95,15 +108,86 @@ export class LineChart extends LitElement { transition: opacity 0.25s ease; } - .line-path:hover { - opacity: 0.7; - } - .area-path { opacity: 0.08; transition: opacity 0.25s ease; } + .dot-indicator { + pointer-events: none; + } + + .crosshair { + stroke-dasharray: 3 3; + pointer-events: none; + } + + .overlay { + fill: transparent; + cursor: crosshair; + } + + .tooltip { + position: absolute; + pointer-events: auto; + background: var(--color-bg-nav); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 0.5rem 0.65rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 120px; + z-index: 10; + } + + .tooltip-header { + font-size: 0.7rem; + font-weight: 700; + color: var(--color-text); + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 0.15rem; + } + + .tooltip-scroll { + display: flex; + flex-direction: column; + gap: 0.2rem; + max-height: 130px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--color-text) 20%, transparent) + transparent; + } + + .tooltip-row { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.72rem; + white-space: nowrap; + } + + .tooltip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .tooltip-label { + color: color-mix(in srgb, var(--color-text) 65%, transparent); + font-weight: 500; + } + + .tooltip-value { + margin-left: auto; + font-weight: 700; + color: var(--color-text); + } + .legend { display: flex; gap: 1rem; @@ -138,6 +222,8 @@ export class LineChart extends LitElement { @property({ type: Array }) series: LineSeries[] = []; @property({ type: Boolean }) showArea = false; + @state() private _hover: HoverState | null = null; + private defaultColors = [ 'var(--color-accent)', '#30a46c', @@ -166,6 +252,106 @@ export class LineChart extends LitElement { return { min: nMin, max: nMax, step }; } + private _formatVal(v: number): string { + return Number.isInteger(v) ? v.toString() : v.toFixed(2); + } + + private _onMouseMove( + e: MouseEvent, + sx: (v: number) => number, + sy: (v: number) => number, + xScale: { min: number; max: number } + ) { + const svgEl = this.renderRoot.querySelector('svg'); + if (!svgEl) return; + + const rect = svgEl.getBoundingClientRect(); + const ratioX = this.width / rect.width; + const ratioY = this.height / rect.height; + const mouseVpX = (e.clientX - rect.left) * ratioX; + const mouseVpY = (e.clientY - rect.top) * ratioY; + + const p = this.padding; + const w = this.width - p.left - p.right; + const xRange = xScale.max - xScale.min || 1; + const dataX = xScale.min + ((mouseVpX - p.left) / w) * xRange; + + let closestX = Infinity; + let closestDist = Infinity; + + for (const s of this.series) { + for (const pt of s.points) { + const dist = Math.abs(pt.x - dataX); + if (dist < closestDist) { + closestDist = dist; + closestX = pt.x; + } + } + } + + if (!isFinite(closestX)) { + this._hover = null; + return; + } + + const points: SnapPoint[] = []; + + this.series.forEach((s, i) => { + const color = + s.color || this.defaultColors[i % this.defaultColors.length]; + const sorted = [...s.points].sort((a, b) => a.x - b.x); + + const exact = sorted.find((pt) => pt.x === closestX); + if (exact) { + points.push({ + label: s.label, + color, + x: exact.x, + y: exact.y, + screenX: sx(exact.x), + screenY: sy(exact.y), + }); + return; + } + + let left = null; + let right = null; + for (let j = 0; j < sorted.length; j++) { + if (sorted[j].x <= closestX) left = sorted[j]; + if (sorted[j].x >= closestX && !right) right = sorted[j]; + } + + if (left && right && left !== right) { + const t = (closestX - left.x) / (right.x - left.x); + const interpY = left.y + t * (right.y - left.y); + points.push({ + label: s.label, + color, + x: closestX, + y: interpY, + screenX: sx(closestX), + screenY: sy(interpY), + }); + } + }); + + if (!points.length) { + this._hover = null; + return; + } + + this._hover = { + snapX: sx(closestX), + pctX: (sx(closestX) / this.width) * 100, + pctY: (mouseVpY / this.height) * 100, + points, + }; + } + + private _onMouseLeave() { + this._hover = null; + } + render() { const p = this.padding; const w = this.width - p.left - p.right; @@ -220,8 +406,7 @@ export class LineChart extends LitElement { const d = sorted .map( - (pt, j) => - `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}` + (pt, j) => `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}` ) .join(' '); @@ -236,6 +421,75 @@ export class LineChart extends LitElement { `; }); + const hoverOverlay = svg` + + this._onMouseMove(e, sx, sy, xScale)} + /> + `; + + const hoverMarkers = this._hover + ? svg` + + ${this._hover.points.map( + (pt) => svg` + + ` + )} + ` + : nothing; + + const tooltipHtml = this._hover + ? (() => { + const hv = this._hover; + const onRight = hv.pctX <= 60; + const tooltipY = Math.max(0, Math.min(hv.pctY - 10, 70)); + + const posStyle = onRight + ? `left: calc(${hv.pctX}% + 16px);` + : `right: calc(${100 - hv.pctX}% + 16px);`; + + return html` +
+
+ ${this.xLabel}: ${this._formatVal(hv.points[0].x)} +
+
+ ${hv.points.map( + (pt) => html` +
+ + ${pt.label} + + ${this._formatVal(pt.y)} + +
+ ` + )} +
+
+ `; + })() + : nothing; + return html`
${this.heading || this.subtitle @@ -243,19 +497,22 @@ export class LineChart extends LitElement {
${this.heading ? html`

${this.heading}

` - : null} + : nothing} ${this.subtitle ? html`

${this.subtitle}

` - : null} + : nothing}
` - : null} -
+ : nothing} +
this._onMouseLeave()} + > - ${gridLines} ${lines} + ${gridLines} ${lines} ${hoverMarkers} ${hoverOverlay} + ${tooltipHtml}
${this.series.length > 1 ? html` @@ -284,9 +542,7 @@ export class LineChart extends LitElement { ${s.label}
@@ -294,7 +550,7 @@ export class LineChart extends LitElement { )}
` - : null} + : nothing} `; } diff --git a/flightscore/src/pages/dev-page.ts b/flightscore/src/pages/dev-page.ts index 30927c8..9dfb2ff 100644 --- a/flightscore/src/pages/dev-page.ts +++ b/flightscore/src/pages/dev-page.ts @@ -621,7 +621,7 @@ export class DevPage extends LitElement { label: 'Pilot A', color: 'var(--color-accent)', points: [ - { x: 0, y: 420 }, + { x: 0, y: 20 }, { x: 1, y: 420 }, { x: 2, y: 580 }, { x: 3, y: 540 }, @@ -640,6 +640,12 @@ export class DevPage extends LitElement { { x: 4, y: 590 }, { x: 5, y: 750 }, { x: 6, y: 780 }, + { x: 7, y: 780 }, + { x: 8, y: 780 }, + { x: 9, y: 780 }, + { x: 10, y: 780 }, + { x: 11, y: 780 }, + { x: 12, y: 780 }, ], }, ]}