fixed line charts

This commit is contained in:
CodingPhoenixx
2026-02-13 17:53:11 +01:00
parent 3477e74a83
commit 4f5ae60f5e
2 changed files with 285 additions and 23 deletions
+278 -22
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css, svg } from 'lit'; import { LitElement, html, css, svg, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
export interface LineSeries { export interface LineSeries {
label: string; label: string;
@@ -7,6 +7,22 @@ export interface LineSeries {
points: { x: number; y: number }[]; 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') @customElement('line-chart')
export class LineChart extends LitElement { export class LineChart extends LitElement {
static styles = css` static styles = css`
@@ -29,11 +45,7 @@ export class LineChart extends LitElement {
} }
.chart-wrapper:hover { .chart-wrapper:hover {
border-color: color-mix( border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
in srgb,
var(--color-accent) 40%,
transparent
);
} }
.header { .header {
@@ -62,6 +74,7 @@ export class LineChart extends LitElement {
flex: 1 1 auto; flex: 1 1 auto;
aspect-ratio: 500 / 300; aspect-ratio: 500 / 300;
overflow: visible; overflow: visible;
position: relative;
} }
:host([style*='height']) .svg-container { :host([style*='height']) .svg-container {
@@ -95,15 +108,86 @@ export class LineChart extends LitElement {
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
} }
.line-path:hover {
opacity: 0.7;
}
.area-path { .area-path {
opacity: 0.08; opacity: 0.08;
transition: opacity 0.25s ease; 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 { .legend {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -138,6 +222,8 @@ export class LineChart extends LitElement {
@property({ type: Array }) series: LineSeries[] = []; @property({ type: Array }) series: LineSeries[] = [];
@property({ type: Boolean }) showArea = false; @property({ type: Boolean }) showArea = false;
@state() private _hover: HoverState | null = null;
private defaultColors = [ private defaultColors = [
'var(--color-accent)', 'var(--color-accent)',
'#30a46c', '#30a46c',
@@ -166,6 +252,106 @@ export class LineChart extends LitElement {
return { min: nMin, max: nMax, step }; 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() { render() {
const p = this.padding; const p = this.padding;
const w = this.width - p.left - p.right; const w = this.width - p.left - p.right;
@@ -220,8 +406,7 @@ export class LineChart extends LitElement {
const d = sorted const d = sorted
.map( .map(
(pt, j) => (pt, j) => `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}`
`${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}`
) )
.join(' '); .join(' ');
@@ -236,6 +421,75 @@ export class LineChart extends LitElement {
`; `;
}); });
const hoverOverlay = svg`
<rect
class="overlay"
x="${p.left}" y="${p.top}"
width="${w}" height="${h}"
@mousemove=${(e: MouseEvent) =>
this._onMouseMove(e, sx, sy, xScale)}
/>
`;
const hoverMarkers = this._hover
? svg`
<line
class="crosshair"
x1="${this._hover.snapX}" y1="${p.top}"
x2="${this._hover.snapX}" y2="${p.top + h}"
stroke="var(--color-text)" stroke-opacity="0.15"
/>
${this._hover.points.map(
(pt) => svg`
<circle
class="dot-indicator"
cx="${pt.screenX}" cy="${pt.screenY}"
r="4.5"
fill="${pt.color}"
stroke="var(--color-bg-nav)"
stroke-width="2"
/>
`
)}
`
: 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`
<div class="tooltip" style="${posStyle} top: ${tooltipY}%;">
<div class="tooltip-header">
${this.xLabel}: ${this._formatVal(hv.points[0].x)}
</div>
<div class="tooltip-scroll">
${hv.points.map(
(pt) => html`
<div class="tooltip-row">
<span
class="tooltip-dot"
style="background:${pt.color}"
></span>
<span class="tooltip-label">${pt.label}</span>
<span class="tooltip-value">
${this._formatVal(pt.y)}
</span>
</div>
`
)}
</div>
</div>
`;
})()
: nothing;
return html` return html`
<div class="chart-wrapper"> <div class="chart-wrapper">
${this.heading || this.subtitle ${this.heading || this.subtitle
@@ -243,19 +497,22 @@ export class LineChart extends LitElement {
<div class="header"> <div class="header">
${this.heading ${this.heading
? html`<h3 class="title">${this.heading}</h3>` ? html`<h3 class="title">${this.heading}</h3>`
: null} : nothing}
${this.subtitle ${this.subtitle
? html`<p class="subtitle">${this.subtitle}</p>` ? html`<p class="subtitle">${this.subtitle}</p>`
: null} : nothing}
</div> </div>
` `
: null} : nothing}
<div class="svg-container"> <div
class="svg-container"
@mouseleave=${() => this._onMouseLeave()}
>
<svg <svg
viewBox="0 0 ${this.width} ${this.height}" viewBox="0 0 ${this.width} ${this.height}"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
${gridLines} ${lines} ${gridLines} ${lines} ${hoverMarkers} ${hoverOverlay}
<text <text
class="axis-label" class="axis-label"
x="${this.width / 2}" x="${this.width / 2}"
@@ -274,6 +531,7 @@ export class LineChart extends LitElement {
${this.yLabel} ${this.yLabel}
</text> </text>
</svg> </svg>
${tooltipHtml}
</div> </div>
${this.series.length > 1 ${this.series.length > 1
? html` ? html`
@@ -284,9 +542,7 @@ export class LineChart extends LitElement {
<span <span
class="legend-line" class="legend-line"
style="background: ${s.color || style="background: ${s.color ||
this.defaultColors[ this.defaultColors[i % this.defaultColors.length]}"
i % this.defaultColors.length
]}"
></span> ></span>
<span class="legend-label">${s.label}</span> <span class="legend-label">${s.label}</span>
</div> </div>
@@ -294,7 +550,7 @@ export class LineChart extends LitElement {
)} )}
</div> </div>
` `
: null} : nothing}
</div> </div>
`; `;
} }
+7 -1
View File
@@ -621,7 +621,7 @@ export class DevPage extends LitElement {
label: 'Pilot A', label: 'Pilot A',
color: 'var(--color-accent)', color: 'var(--color-accent)',
points: [ points: [
{ x: 0, y: 420 }, { x: 0, y: 20 },
{ x: 1, y: 420 }, { x: 1, y: 420 },
{ x: 2, y: 580 }, { x: 2, y: 580 },
{ x: 3, y: 540 }, { x: 3, y: 540 },
@@ -640,6 +640,12 @@ export class DevPage extends LitElement {
{ x: 4, y: 590 }, { x: 4, y: 590 },
{ x: 5, y: 750 }, { x: 5, y: 750 },
{ x: 6, y: 780 }, { 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 },
], ],
}, },
]} ]}