fixed line charts
This commit is contained in:
@@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user