fixed line charts
This commit is contained in:
@@ -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`
|
||||
<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`
|
||||
<div class="chart-wrapper">
|
||||
${this.heading || this.subtitle
|
||||
@@ -243,19 +497,22 @@ export class LineChart extends LitElement {
|
||||
<div class="header">
|
||||
${this.heading
|
||||
? html`<h3 class="title">${this.heading}</h3>`
|
||||
: null}
|
||||
: nothing}
|
||||
${this.subtitle
|
||||
? html`<p class="subtitle">${this.subtitle}</p>`
|
||||
: null}
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div class="svg-container">
|
||||
: nothing}
|
||||
<div
|
||||
class="svg-container"
|
||||
@mouseleave=${() => this._onMouseLeave()}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 ${this.width} ${this.height}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
${gridLines} ${lines}
|
||||
${gridLines} ${lines} ${hoverMarkers} ${hoverOverlay}
|
||||
<text
|
||||
class="axis-label"
|
||||
x="${this.width / 2}"
|
||||
@@ -274,6 +531,7 @@ export class LineChart extends LitElement {
|
||||
${this.yLabel}
|
||||
</text>
|
||||
</svg>
|
||||
${tooltipHtml}
|
||||
</div>
|
||||
${this.series.length > 1
|
||||
? html`
|
||||
@@ -284,9 +542,7 @@ export class LineChart extends LitElement {
|
||||
<span
|
||||
class="legend-line"
|
||||
style="background: ${s.color ||
|
||||
this.defaultColors[
|
||||
i % this.defaultColors.length
|
||||
]}"
|
||||
this.defaultColors[i % this.defaultColors.length]}"
|
||||
></span>
|
||||
<span class="legend-label">${s.label}</span>
|
||||
</div>
|
||||
@@ -294,7 +550,7 @@ export class LineChart extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user