multiline-chart 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # multiline-chart
2
+
3
+ A lightweight, dependency-free multi-line time-series chart, packaged as a native
4
+ [Web Component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).
5
+ It renders multiple data series on a `<canvas>` with interactive hover
6
+ scrubbing, a live legend, and full responsive/touch support.
7
+
8
+ Everything (markup, styles, and rendering logic) is encapsulated in a single
9
+ custom element, `<multi-line-chart>`, using Shadow DOM, so it drops into any page
10
+ or framework without style leakage and without external libraries.
11
+
12
+ ## Features
13
+
14
+ - **Zero runtime dependencies** – just plain TypeScript compiled to JavaScript.
15
+ - **Canvas rendering** – crisp lines on any screen via `devicePixelRatio` scaling.
16
+ - **Interactive hover** – a tracking line, date tooltip, on-line value dots, and
17
+ spread-out labels that avoid overlapping.
18
+ - **Live legend** – shows the current value per series and updates as you hover.
19
+ - **Responsive** – redraws on window resize and adapts label sizing/alignment on
20
+ small screens.
21
+ - **Touch support** – scrub the chart on mobile; taps still pass through, drags
22
+ prevent page scroll.
23
+ - **Dynamic Y-axis scaling** – axis bounds are computed from the data and rounded
24
+ to clean intervals.
25
+ - **Sparse-data friendly** – series can have different/missing dates; missing
26
+ points are interpolated for hover markers.
27
+ - **Encapsulated styling** – CSS is bundled into the component via Shadow DOM.
28
+ - **Customizable title and logo** – via the `title` attribute and a `logo` slot.
29
+
30
+ ## Project structure
31
+
32
+ ```
33
+ multiline-chart/
34
+ ├── src/
35
+ │ ├── index.ts # MultiLineChart custom element (all chart logic)
36
+ │ ├── style.css # Component styles (source of truth)
37
+ │ └── chart-styles.ts # AUTO-GENERATED from style.css (do not edit)
38
+ ├── scripts/
39
+ │ └── inject-styles.js # Inlines style.css into chart-styles.ts at build time
40
+ ├── dist/ # Compiled output (generated by build)
41
+ ├── package.json
42
+ └── tsconfig.json
43
+ ```
44
+
45
+ The build has two steps (wired together in the `build` script):
46
+
47
+ 1. `scripts/inject-styles.js` reads `src/style.css` and writes it into
48
+ `src/chart-styles.ts` as an exported `CHART_CSS` string. This bundles the CSS
49
+ into the JS output without needing a CSS-aware bundler.
50
+ 2. `tsc` compiles the TypeScript in `src/` to `dist/`.
51
+
52
+ ## Getting started
53
+
54
+ ### Prerequisites
55
+
56
+ - [Node.js](https://nodejs.org/) and npm
57
+
58
+ ### Install
59
+
60
+ ```bash
61
+ npm install
62
+ ```
63
+
64
+ ### Build
65
+
66
+ ```bash
67
+ npm run build
68
+ ```
69
+
70
+ This regenerates `src/chart-styles.ts` from `src/style.css` and compiles the
71
+ TypeScript into `dist/`.
72
+
73
+ ## Usage
74
+
75
+ Import the compiled module and register the element, then use the
76
+ `<multi-line-chart>` tag in your HTML.
77
+
78
+ ```html
79
+ <multi-line-chart title="Market Share">
80
+ <!-- optional: any markup projected into the header logo slot -->
81
+ <img slot="logo" src="logo.svg" alt="Logo" width="120" />
82
+ </multi-line-chart>
83
+
84
+ <script type="module">
85
+ import { MultiLineChart } from './dist/index.js';
86
+
87
+ // Register the <multi-line-chart> custom element.
88
+ MultiLineChart.initialize();
89
+
90
+ const chart = document.querySelector('multi-line-chart');
91
+
92
+ chart.setData([
93
+ {
94
+ name: 'Series A',
95
+ data: [
96
+ { date: '2024-01-01', value: 0.42 },
97
+ { date: '2024-02-01', value: 0.45 },
98
+ { date: '2024-03-01', value: 0.51 },
99
+ ],
100
+ },
101
+ {
102
+ name: 'Series B',
103
+ data: [
104
+ { date: '2024-01-01', value: 0.30 },
105
+ { date: '2024-02-01', value: 0.28 },
106
+ { date: '2024-03-01', value: 0.35 },
107
+ ],
108
+ },
109
+ ]);
110
+ </script>
111
+ ```
112
+
113
+ You can call `setData()` before the element is attached to the DOM; the data is
114
+ buffered and rendered once it connects.
115
+
116
+ ## API
117
+
118
+ ### `MultiLineChart.initialize()`
119
+
120
+ Static method that registers the custom element under the tag name
121
+ `multi-line-chart` (no-op if already registered).
122
+
123
+ ### `setData(lines)`
124
+
125
+ Sets the chart data and triggers a render.
126
+
127
+ ```ts
128
+ interface DataPoint {
129
+ date: string; // any value parseable by `new Date(...)`
130
+ value: number; // fractional value; rendered as a percentage (value * 100)
131
+ }
132
+
133
+ interface InputLine {
134
+ name: string; // series name shown in the legend / hover labels
135
+ data: DataPoint[];
136
+ }
137
+
138
+ setData(lines: InputLine[]): void;
139
+ ```
140
+
141
+ Notes:
142
+
143
+ - `value` is treated as a fraction and displayed as a percentage (e.g. `0.42`
144
+ renders as `42%`).
145
+ - Series are sorted by their leading value and assigned colors automatically from
146
+ the built-in color scheme.
147
+ - Dates are unioned across all series; gaps within a series are interpolated for
148
+ hover markers.
149
+
150
+ ### `title` attribute
151
+
152
+ Sets the header title. Changing it after render updates the header in place.
153
+
154
+ ```html
155
+ <multi-line-chart title="Quarterly Revenue"></multi-line-chart>
156
+ ```
157
+
158
+ ### `logo` slot
159
+
160
+ Project arbitrary markup (e.g. an inline SVG or image) into the header's logo
161
+ area using `slot="logo"`.
162
+
163
+ ## Development
164
+
165
+ - Edit styles in `src/style.css` (not `src/chart-styles.ts`, which is generated).
166
+ - Run `npm run build` after changes to regenerate styles and recompile.
167
+
168
+ ## License
169
+
170
+ ISC
@@ -0,0 +1 @@
1
+ export declare const CHART_CSS = ":host {\n box-sizing: border-box;\n font-variation-settings:\n 'slnt' 0,\n 'wdth' 100,\n 'ital' 0;\n}\n\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\n.link-wrapper {\n padding: 20px;\n text-decoration: none;\n}\n\n.chart-header {\n display: flex;\n margin-bottom: 20px;\n justify-content: space-between;\n align-items: center;\n}\n\n.chart-header__title {\n font-size: 30px;\n color: black;\n}\n\n.chart-container {\n max-width: 100%;\n max-height: 100%;\n margin: 0 auto;\n background: white;\n padding: 30px;\n border-radius: 12px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n position: relative;\n}\n\n.chart-legend {\n display: flex;\n gap: 24px;\n margin-bottom: 24px;\n flex-wrap: wrap;\n}\n\n.legend-item {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 14px;\n}\n\n.legend-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n}\n\n.legend-text {\n color: #333;\n font-weight: 500;\n}\n\n.legend-value {\n color: #111;\n font-weight: 700;\n}\n\n.canvas-wrapper {\n position: relative;\n}\n\ncanvas {\n display: block;\n cursor: crosshair;\n max-width: 100%;\n}\n\n.hover-tooltip {\n position: absolute;\n font-size: 12px;\n pointer-events: none;\n display: none;\n white-space: nowrap;\n z-index: 1000;\n color: #999;\n font-weight: 600;\n letter-spacing: 0.5px;\n}\n\n.hover-line {\n position: absolute;\n width: 1px;\n background: #999;\n pointer-events: none;\n display: none;\n z-index: 999;\n}\n\n.hover-labels {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n z-index: 1001;\n}\n\n.hover-label {\n position: absolute;\n transform: translateY(-50%);\n font-size: 13px;\n font-weight: 700;\n white-space: nowrap;\n pointer-events: none;\n}\n";
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CHART_CSS = void 0;
4
+ // AUTO-GENERATED – do not edit. Run `npm run build` to regenerate.
5
+ // Source: src/style.css
6
+ exports.CHART_CSS = `:host {
7
+ box-sizing: border-box;
8
+ font-variation-settings:
9
+ 'slnt' 0,
10
+ 'wdth' 100,
11
+ 'ital' 0;
12
+ }
13
+
14
+ * {
15
+ margin: 0;
16
+ padding: 0;
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ .link-wrapper {
21
+ padding: 20px;
22
+ text-decoration: none;
23
+ }
24
+
25
+ .chart-header {
26
+ display: flex;
27
+ margin-bottom: 20px;
28
+ justify-content: space-between;
29
+ align-items: center;
30
+ }
31
+
32
+ .chart-header__title {
33
+ font-size: 30px;
34
+ color: black;
35
+ }
36
+
37
+ .chart-container {
38
+ max-width: 100%;
39
+ max-height: 100%;
40
+ margin: 0 auto;
41
+ background: white;
42
+ padding: 30px;
43
+ border-radius: 12px;
44
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
45
+ position: relative;
46
+ }
47
+
48
+ .chart-legend {
49
+ display: flex;
50
+ gap: 24px;
51
+ margin-bottom: 24px;
52
+ flex-wrap: wrap;
53
+ }
54
+
55
+ .legend-item {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 8px;
59
+ font-size: 14px;
60
+ }
61
+
62
+ .legend-dot {
63
+ width: 10px;
64
+ height: 10px;
65
+ border-radius: 50%;
66
+ }
67
+
68
+ .legend-text {
69
+ color: #333;
70
+ font-weight: 500;
71
+ }
72
+
73
+ .legend-value {
74
+ color: #111;
75
+ font-weight: 700;
76
+ }
77
+
78
+ .canvas-wrapper {
79
+ position: relative;
80
+ }
81
+
82
+ canvas {
83
+ display: block;
84
+ cursor: crosshair;
85
+ max-width: 100%;
86
+ }
87
+
88
+ .hover-tooltip {
89
+ position: absolute;
90
+ font-size: 12px;
91
+ pointer-events: none;
92
+ display: none;
93
+ white-space: nowrap;
94
+ z-index: 1000;
95
+ color: #999;
96
+ font-weight: 600;
97
+ letter-spacing: 0.5px;
98
+ }
99
+
100
+ .hover-line {
101
+ position: absolute;
102
+ width: 1px;
103
+ background: #999;
104
+ pointer-events: none;
105
+ display: none;
106
+ z-index: 999;
107
+ }
108
+
109
+ .hover-labels {
110
+ position: absolute;
111
+ top: 0;
112
+ left: 0;
113
+ width: 100%;
114
+ height: 100%;
115
+ pointer-events: none;
116
+ z-index: 1001;
117
+ }
118
+
119
+ .hover-label {
120
+ position: absolute;
121
+ transform: translateY(-50%);
122
+ font-size: 13px;
123
+ font-weight: 700;
124
+ white-space: nowrap;
125
+ pointer-events: none;
126
+ }
127
+ `;
@@ -0,0 +1,78 @@
1
+ interface DataPoint {
2
+ date: string;
3
+ value: number;
4
+ }
5
+ interface InputLine {
6
+ name: string;
7
+ data: DataPoint[];
8
+ }
9
+ export declare class MultiLineChart extends HTMLElement {
10
+ static get observedAttributes(): string[];
11
+ private root;
12
+ private canvas;
13
+ private ctx;
14
+ private tooltip;
15
+ private hoverLine;
16
+ private hoverLabels;
17
+ private chartLegend;
18
+ private legendValueEls;
19
+ private width;
20
+ private height;
21
+ private padding;
22
+ private lines;
23
+ private allDates;
24
+ private dateLabels;
25
+ private colorScheme;
26
+ private maxValue;
27
+ private minValue;
28
+ private hoverIndex;
29
+ private connected;
30
+ private pendingData;
31
+ private readonly boundMouseMove;
32
+ private readonly boundMouseLeave;
33
+ private readonly boundTouchStart;
34
+ private readonly boundTouchMove;
35
+ private readonly boundResize;
36
+ constructor();
37
+ connectedCallback(): void;
38
+ disconnectedCallback(): void;
39
+ attributeChangedCallback(): void;
40
+ private get chartTitle();
41
+ private headerHTML;
42
+ private render;
43
+ private setupCanvas;
44
+ private calculateDynamicScale;
45
+ setData(inputData: InputLine[]): void;
46
+ private generateDateLabels;
47
+ draw(): void;
48
+ private drawGrid;
49
+ private drawAxes;
50
+ private indexToX;
51
+ private valueToY;
52
+ private getPointCoords;
53
+ /**
54
+ * Resolve the hover marker for a line at `index`. When the line has no sample
55
+ * at that exact index (dates differ between lines), interpolate the value
56
+ * along the drawn segment so the dot/label still appear on the line.
57
+ */
58
+ private resolveHoverPoint;
59
+ private strokeLine;
60
+ private drawLines;
61
+ private drawHoverHighlights;
62
+ private clearHoverLabels;
63
+ private showHoverElements;
64
+ private updateHover;
65
+ private onMouseMove;
66
+ private onTouchStart;
67
+ private onTouchMove;
68
+ private handleTouch;
69
+ private onMouseLeave;
70
+ private handleResize;
71
+ private renderLegend;
72
+ /** Update legend values to those of the hovered point. */
73
+ private updateLegendValues;
74
+ /** Restore legend values to the default (current) percentages. */
75
+ private resetLegendValues;
76
+ static initialize(): void;
77
+ }
78
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,596 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MultiLineChart = void 0;
4
+ const chart_styles_1 = require("./chart-styles");
5
+ class MultiLineChart extends HTMLElement {
6
+ static get observedAttributes() {
7
+ return ['title'];
8
+ }
9
+ constructor() {
10
+ super();
11
+ this.legendValueEls = [];
12
+ this.width = 0;
13
+ this.height = 0;
14
+ this.padding = {
15
+ top: 20,
16
+ right: 60,
17
+ bottom: 40,
18
+ left: 20,
19
+ };
20
+ this.lines = [];
21
+ this.allDates = [];
22
+ this.dateLabels = [];
23
+ this.colorScheme = ['#09C285', '#FF8A00', '#265cff', '#000000'];
24
+ this.maxValue = 100;
25
+ this.minValue = 0;
26
+ this.hoverIndex = -1;
27
+ this.connected = false;
28
+ this.pendingData = null;
29
+ // Bound handlers kept as fields so they can be removed on disconnect.
30
+ this.boundMouseMove = this.onMouseMove.bind(this);
31
+ this.boundMouseLeave = this.onMouseLeave.bind(this);
32
+ this.boundTouchStart = this.onTouchStart.bind(this);
33
+ this.boundTouchMove = this.onTouchMove.bind(this);
34
+ this.boundResize = this.handleResize.bind(this);
35
+ this.root = this.attachShadow({ mode: 'open' });
36
+ }
37
+ connectedCallback() {
38
+ if (this.connected)
39
+ return;
40
+ this.connected = true;
41
+ this.render();
42
+ this.canvas = this.root.querySelector('canvas');
43
+ this.ctx = this.canvas.getContext('2d');
44
+ this.tooltip = this.root.querySelector('.hover-tooltip');
45
+ this.hoverLine = this.root.querySelector('.hover-line');
46
+ this.hoverLabels = this.root.querySelector('.hover-labels');
47
+ this.chartLegend = this.root.querySelector('.chart-legend');
48
+ this.setupCanvas();
49
+ this.canvas.addEventListener('mousemove', this.boundMouseMove);
50
+ this.canvas.addEventListener('mouseleave', this.boundMouseLeave);
51
+ // Touch scrubbing on mobile. touchstart stays passive so a plain tap still
52
+ // follows the clickout link; touchmove is non-passive so dragging the chart
53
+ // can preventDefault page scroll without losing the hover interaction.
54
+ this.canvas.addEventListener('touchstart', this.boundTouchStart, {
55
+ passive: true,
56
+ });
57
+ this.canvas.addEventListener('touchmove', this.boundTouchMove, {
58
+ passive: false,
59
+ });
60
+ this.canvas.addEventListener('touchend', this.boundMouseLeave);
61
+ window.addEventListener('resize', this.boundResize);
62
+ if (this.pendingData) {
63
+ const data = this.pendingData;
64
+ this.pendingData = null;
65
+ this.setData(data);
66
+ }
67
+ }
68
+ disconnectedCallback() {
69
+ if (!this.connected)
70
+ return;
71
+ this.connected = false;
72
+ this.canvas.removeEventListener('mousemove', this.boundMouseMove);
73
+ this.canvas.removeEventListener('mouseleave', this.boundMouseLeave);
74
+ this.canvas.removeEventListener('touchstart', this.boundTouchStart);
75
+ this.canvas.removeEventListener('touchmove', this.boundTouchMove);
76
+ this.canvas.removeEventListener('touchend', this.boundMouseLeave);
77
+ window.removeEventListener('resize', this.boundResize);
78
+ }
79
+ attributeChangedCallback() {
80
+ // Re-render the header so title attribute changes are reflected. The logo
81
+ // slot content is projected from light DOM and updates automatically.
82
+ if (this.connected) {
83
+ const header = this.root.querySelector('.chart-header');
84
+ if (header)
85
+ header.innerHTML = this.headerHTML();
86
+ }
87
+ }
88
+ get chartTitle() {
89
+ return this.getAttribute('title') || '';
90
+ }
91
+ headerHTML() {
92
+ // The logo is projected from light DOM via the "logo" slot, so consumers
93
+ // can pass arbitrary markup (e.g. an inline <svg slot="logo">...</svg>).
94
+ return `
95
+ ${this.chartTitle ? `<div class="chart-header__title">${this.chartTitle}</div>` : ''}
96
+ <div class="chart-header__logo"><slot name="logo"></slot></div>
97
+ `;
98
+ }
99
+ render() {
100
+ this.root.innerHTML = `
101
+ <style>${chart_styles_1.CHART_CSS}</style>
102
+ <div class="chart-container">
103
+ <div class="chart-header">${this.headerHTML()}</div>
104
+ <div class="chart-legend"></div>
105
+ <div class="canvas-wrapper">
106
+ <canvas></canvas>
107
+ <div class="hover-line"></div>
108
+ <div class="hover-tooltip"></div>
109
+ <div class="hover-labels"></div>
110
+ </div>
111
+ </div>
112
+ `;
113
+ }
114
+ setupCanvas() {
115
+ this.height = 250;
116
+ this.canvas.style.width = '100%';
117
+ this.canvas.style.height = `${this.height}px`;
118
+ // Measure the canvas itself (not the outer wrapper, which has paddings),
119
+ // so the drawing coordinate space matches the rendered canvas pixel-for-pixel.
120
+ const rect = this.canvas.getBoundingClientRect();
121
+ this.width = rect.width;
122
+ const dpr = window.devicePixelRatio || 1;
123
+ this.canvas.width = this.width * dpr;
124
+ this.canvas.height = this.height * dpr;
125
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
126
+ this.ctx.scale(dpr, dpr);
127
+ }
128
+ calculateDynamicScale() {
129
+ let max = 0;
130
+ let min = Infinity;
131
+ this.lines.forEach((line) => {
132
+ line.data.forEach((point) => {
133
+ if (point.value !== null) {
134
+ max = Math.max(max, point.value);
135
+ min = Math.min(min, point.value);
136
+ }
137
+ });
138
+ });
139
+ this.maxValue = Math.ceil(max / 10) * 10;
140
+ this.minValue = Math.floor(min / 10) * 10;
141
+ if (this.maxValue - this.minValue < 20) {
142
+ const center = (this.maxValue + this.minValue) / 2;
143
+ this.maxValue = center + 10;
144
+ this.minValue = Math.max(0, center - 10);
145
+ }
146
+ }
147
+ setData(inputData) {
148
+ // Allow setData before the element is attached to the DOM.
149
+ if (!this.connected) {
150
+ this.pendingData = inputData;
151
+ return;
152
+ }
153
+ this.lines = [];
154
+ const datesSet = new Set();
155
+ inputData.forEach((line) => {
156
+ line.data.forEach((point) => {
157
+ datesSet.add(point.date);
158
+ });
159
+ });
160
+ this.allDates = Array.from(datesSet).sort();
161
+ const processedLines = inputData.map((line) => {
162
+ const dataMap = new Map();
163
+ line.data.forEach((point) => {
164
+ dataMap.set(point.date, point.value);
165
+ });
166
+ const fullData = this.allDates.map((dateStr) => {
167
+ const value = dataMap.get(dateStr);
168
+ return {
169
+ date: new Date(dateStr),
170
+ value: value !== undefined ? value * 100 : null,
171
+ };
172
+ });
173
+ const originalLastValue = line.data[0]?.value || 0;
174
+ const percentage = originalLastValue * 100;
175
+ return {
176
+ name: line.name,
177
+ data: fullData,
178
+ percentage: percentage,
179
+ originalData: line.data,
180
+ color: '',
181
+ };
182
+ });
183
+ processedLines.sort((a, b) => b.percentage - a.percentage);
184
+ this.lines = processedLines.map((line, index) => ({
185
+ ...line,
186
+ color: this.colorScheme[index % this.colorScheme.length],
187
+ }));
188
+ this.calculateDynamicScale();
189
+ this.generateDateLabels();
190
+ this.renderLegend();
191
+ this.draw();
192
+ }
193
+ generateDateLabels() {
194
+ const numLabels = 5;
195
+ const labels = [];
196
+ const totalDates = this.allDates.length;
197
+ if (totalDates === 0) {
198
+ this.dateLabels = [];
199
+ return;
200
+ }
201
+ const monthNames = [
202
+ 'Jan',
203
+ 'Feb',
204
+ 'Mar',
205
+ 'Apr',
206
+ 'May',
207
+ 'Jun',
208
+ 'Jul',
209
+ 'Aug',
210
+ 'Sep',
211
+ 'Oct',
212
+ 'Nov',
213
+ 'Dec',
214
+ ];
215
+ // When the period spans more than one calendar year, show month + year
216
+ // instead of month + day so labels stay unambiguous across the boundary.
217
+ const firstYear = new Date(this.allDates[0]).getFullYear();
218
+ const lastYear = new Date(this.allDates[totalDates - 1]).getFullYear();
219
+ const spansMultipleYears = firstYear !== lastYear;
220
+ for (let i = 0; i < numLabels; i++) {
221
+ const index = Math.floor(((totalDates - 1) * i) / (numLabels - 1));
222
+ const dateStr = this.allDates[index];
223
+ const date = new Date(dateStr);
224
+ const label = spansMultipleYears
225
+ ? `${monthNames[date.getMonth()]} ${date.getFullYear()}`
226
+ : `${monthNames[date.getMonth()]} ${date.getDate()}`;
227
+ labels.push(label);
228
+ }
229
+ this.dateLabels = labels;
230
+ }
231
+ draw() {
232
+ if (!this.connected)
233
+ return;
234
+ this.ctx.globalAlpha = 1;
235
+ this.ctx.clearRect(0, 0, this.width, this.height);
236
+ this.drawGrid();
237
+ this.drawAxes();
238
+ this.drawLines();
239
+ }
240
+ drawGrid() {
241
+ this.ctx.strokeStyle = '#e8e8e8';
242
+ this.ctx.lineWidth = 1;
243
+ const chartHeight = this.height - this.padding.top - this.padding.bottom;
244
+ const steps = 4;
245
+ const valueRange = this.maxValue - this.minValue;
246
+ for (let i = 0; i <= steps; i++) {
247
+ const y = this.padding.top + (chartHeight * i) / steps;
248
+ this.ctx.setLineDash([3, 3]);
249
+ this.ctx.beginPath();
250
+ this.ctx.moveTo(this.padding.left, y);
251
+ this.ctx.lineTo(this.width - this.padding.right, y);
252
+ this.ctx.stroke();
253
+ this.ctx.setLineDash([]);
254
+ const value = this.maxValue - (valueRange * i) / steps;
255
+ this.ctx.fillStyle = '#999';
256
+ this.ctx.font = '12px sans-serif';
257
+ this.ctx.textAlign = 'left';
258
+ this.ctx.textBaseline = 'middle';
259
+ this.ctx.fillText(`${value.toFixed(0)}%`, this.width - this.padding.right + 10, y);
260
+ }
261
+ }
262
+ drawAxes() {
263
+ const chartWidth = this.width - this.padding.left - this.padding.right;
264
+ const fontSize = this.width < 480 ? 9 : 12;
265
+ this.ctx.fillStyle = '#999';
266
+ this.ctx.font = `${fontSize}px sans-serif`;
267
+ this.ctx.textBaseline = 'top';
268
+ const lastIndex = this.dateLabels.length - 1;
269
+ const isMobile = this.width < 480;
270
+ this.dateLabels.forEach((label, i) => {
271
+ const x = this.padding.left + (chartWidth * i) / lastIndex;
272
+ // On desktop, align edge labels inward so they don't clip past the canvas
273
+ // edges. On mobile keep everything centered (previous behavior).
274
+ if (!isMobile && i === 0) {
275
+ this.ctx.textAlign = 'left';
276
+ }
277
+ else if (!isMobile && i === lastIndex) {
278
+ this.ctx.textAlign = 'right';
279
+ }
280
+ else {
281
+ this.ctx.textAlign = 'center';
282
+ }
283
+ this.ctx.fillText(label, x, this.height - this.padding.bottom + 10);
284
+ });
285
+ }
286
+ indexToX(i, length) {
287
+ const chartWidth = this.width - this.padding.left - this.padding.right;
288
+ return this.padding.left + (chartWidth * i) / (length - 1);
289
+ }
290
+ valueToY(value) {
291
+ const chartHeight = this.height - this.padding.top - this.padding.bottom;
292
+ const valueRange = this.maxValue - this.minValue;
293
+ const normalizedValue = (value - this.minValue) / valueRange;
294
+ return this.padding.top + chartHeight - chartHeight * normalizedValue;
295
+ }
296
+ getPointCoords(line, i) {
297
+ const point = line.data[i];
298
+ if (!point || point.value === null)
299
+ return null;
300
+ return {
301
+ x: this.indexToX(i, line.data.length),
302
+ y: this.valueToY(point.value),
303
+ };
304
+ }
305
+ /**
306
+ * Resolve the hover marker for a line at `index`. When the line has no sample
307
+ * at that exact index (dates differ between lines), interpolate the value
308
+ * along the drawn segment so the dot/label still appear on the line.
309
+ */
310
+ resolveHoverPoint(line, index) {
311
+ const x = this.indexToX(index, line.data.length);
312
+ const direct = line.data[index];
313
+ if (direct && direct.value !== null) {
314
+ return { x, y: this.valueToY(direct.value), value: direct.value };
315
+ }
316
+ let prev = -1;
317
+ for (let i = index - 1; i >= 0; i--) {
318
+ if (line.data[i] && line.data[i].value !== null) {
319
+ prev = i;
320
+ break;
321
+ }
322
+ }
323
+ let next = -1;
324
+ for (let i = index + 1; i < line.data.length; i++) {
325
+ if (line.data[i] && line.data[i].value !== null) {
326
+ next = i;
327
+ break;
328
+ }
329
+ }
330
+ if (prev === -1 && next === -1)
331
+ return null;
332
+ let value;
333
+ if (prev === -1) {
334
+ value = line.data[next].value;
335
+ }
336
+ else if (next === -1) {
337
+ value = line.data[prev].value;
338
+ }
339
+ else {
340
+ const pv = line.data[prev].value;
341
+ const nv = line.data[next].value;
342
+ value = pv + (nv - pv) * ((index - prev) / (next - prev));
343
+ }
344
+ return { x, y: this.valueToY(value), value };
345
+ }
346
+ strokeLine(line, startIdx, endIdx) {
347
+ this.ctx.strokeStyle = line.color;
348
+ this.ctx.lineWidth = 1.5;
349
+ this.ctx.lineCap = 'round';
350
+ this.ctx.lineJoin = 'round';
351
+ this.ctx.beginPath();
352
+ let started = false;
353
+ for (let i = startIdx; i <= endIdx; i++) {
354
+ const coords = this.getPointCoords(line, i);
355
+ if (!coords)
356
+ continue;
357
+ if (!started) {
358
+ this.ctx.moveTo(coords.x, coords.y);
359
+ started = true;
360
+ }
361
+ else {
362
+ this.ctx.lineTo(coords.x, coords.y);
363
+ }
364
+ }
365
+ this.ctx.stroke();
366
+ }
367
+ drawLines() {
368
+ const hovering = this.hoverIndex >= 0;
369
+ const lastIdx = this.allDates.length - 1;
370
+ this.lines.forEach((line) => {
371
+ if (hovering) {
372
+ // Whole line dimmed, then solid up to the hovered point (future is faded)
373
+ this.ctx.globalAlpha = 0.25;
374
+ this.strokeLine(line, 0, lastIdx);
375
+ this.ctx.globalAlpha = 1;
376
+ this.strokeLine(line, 0, this.hoverIndex);
377
+ }
378
+ else {
379
+ this.strokeLine(line, 0, lastIdx);
380
+ // Endpoint dot when not hovering
381
+ for (let i = lastIdx; i >= 0; i--) {
382
+ const coords = this.getPointCoords(line, i);
383
+ if (coords) {
384
+ this.ctx.fillStyle = line.color;
385
+ this.ctx.beginPath();
386
+ this.ctx.arc(coords.x, coords.y, 4, 0, Math.PI * 2);
387
+ this.ctx.fill();
388
+ break;
389
+ }
390
+ }
391
+ }
392
+ });
393
+ this.ctx.globalAlpha = 1;
394
+ if (hovering) {
395
+ this.drawHoverHighlights();
396
+ }
397
+ }
398
+ drawHoverHighlights() {
399
+ this.hoverLabels.innerHTML = '';
400
+ const points = this.lines
401
+ .map((line) => ({
402
+ line,
403
+ hp: this.resolveHoverPoint(line, this.hoverIndex),
404
+ }))
405
+ .filter((p) => p.hp !== null);
406
+ // Dots sit exactly on the line.
407
+ points.forEach(({ line, hp }) => {
408
+ this.ctx.fillStyle = line.color;
409
+ this.ctx.beginPath();
410
+ this.ctx.arc(hp.x, hp.y, 4, 0, Math.PI * 2);
411
+ this.ctx.fill();
412
+ });
413
+ // Spread labels vertically so close values don't overlap. Sort by the
414
+ // line's y, then push each label down to keep a minimum gap.
415
+ const minGap = 16;
416
+ const labelY = new Map();
417
+ let lastY = -Infinity;
418
+ [...points]
419
+ .sort((a, b) => a.hp.y - b.hp.y)
420
+ .forEach(({ line, hp }) => {
421
+ const y = Math.max(hp.y, lastY + minGap);
422
+ labelY.set(line, y);
423
+ lastY = y;
424
+ });
425
+ // "<name> <value>%" labels as absolutely positioned DOM nodes so their
426
+ // length never shifts the chart coordinates.
427
+ points.forEach(({ line, hp }) => {
428
+ const label = document.createElement('span');
429
+ label.className = 'hover-label';
430
+ label.textContent = `${line.name} ${hp.value.toFixed(1)}%`;
431
+ label.style.color = line.color;
432
+ label.style.left = `${hp.x + 12}px`;
433
+ label.style.top = `${labelY.get(line)}px`;
434
+ this.hoverLabels.appendChild(label);
435
+ });
436
+ }
437
+ clearHoverLabels() {
438
+ if (this.hoverLabels) {
439
+ this.hoverLabels.innerHTML = '';
440
+ }
441
+ }
442
+ showHoverElements(x, dataIndex) {
443
+ if (dataIndex < 0 || dataIndex >= this.allDates.length) {
444
+ this.hoverLine.style.display = 'none';
445
+ this.tooltip.style.display = 'none';
446
+ this.clearHoverLabels();
447
+ this.resetLegendValues();
448
+ if (this.hoverIndex !== -1) {
449
+ this.hoverIndex = -1;
450
+ this.draw();
451
+ }
452
+ return;
453
+ }
454
+ this.hoverIndex = dataIndex;
455
+ this.draw();
456
+ this.updateLegendValues(dataIndex);
457
+ const chartHeight = this.height - this.padding.top - this.padding.bottom;
458
+ this.hoverLine.style.display = 'block';
459
+ this.hoverLine.style.left = `${x}px`;
460
+ this.hoverLine.style.top = `${this.padding.top}px`;
461
+ this.hoverLine.style.height = `${chartHeight}px`;
462
+ const dateStr = this.allDates[dataIndex];
463
+ const date = new Date(dateStr);
464
+ const monthNames = [
465
+ 'JAN',
466
+ 'FEB',
467
+ 'MAR',
468
+ 'APR',
469
+ 'MAY',
470
+ 'JUN',
471
+ 'JUL',
472
+ 'AUG',
473
+ 'SEP',
474
+ 'OCT',
475
+ 'NOV',
476
+ 'DEC',
477
+ ];
478
+ const hours = date.getHours();
479
+ const ampm = hours >= 12 ? 'PM' : 'AM';
480
+ const hours12 = hours % 12 === 0 ? 12 : hours % 12;
481
+ const formattedDate = `${monthNames[date.getMonth()]} ${date.getDate()}, ${hours12} ${ampm}`;
482
+ this.tooltip.textContent = formattedDate;
483
+ this.tooltip.style.display = 'block';
484
+ const tooltipRect = this.tooltip.getBoundingClientRect();
485
+ let tooltipX = x - tooltipRect.width / 2;
486
+ if (tooltipX < this.padding.left) {
487
+ tooltipX = this.padding.left;
488
+ }
489
+ if (tooltipX + tooltipRect.width > this.width - this.padding.right) {
490
+ tooltipX = this.width - this.padding.right - tooltipRect.width;
491
+ }
492
+ this.tooltip.style.left = `${tooltipX}px`;
493
+ this.tooltip.style.top = `${this.padding.top - 30}px`;
494
+ }
495
+ updateHover(x) {
496
+ const chartWidth = this.width - this.padding.left - this.padding.right;
497
+ const clampedX = Math.max(this.padding.left, Math.min(x, this.width - this.padding.right));
498
+ const dataIndex = Math.round(((clampedX - this.padding.left) / chartWidth) *
499
+ (this.allDates.length - 1));
500
+ this.showHoverElements(clampedX, dataIndex);
501
+ }
502
+ onMouseMove(event) {
503
+ const rect = this.canvas.getBoundingClientRect();
504
+ const x = event.clientX - rect.left;
505
+ const y = event.clientY - rect.top;
506
+ if (x >= this.padding.left &&
507
+ x <= this.width - this.padding.right &&
508
+ y >= this.padding.top &&
509
+ y <= this.height - this.padding.bottom) {
510
+ this.updateHover(x);
511
+ }
512
+ else {
513
+ this.onMouseLeave();
514
+ }
515
+ }
516
+ onTouchStart(event) {
517
+ this.handleTouch(event, false);
518
+ }
519
+ onTouchMove(event) {
520
+ this.handleTouch(event, true);
521
+ }
522
+ handleTouch(event, preventScroll) {
523
+ if (!event.touches || event.touches.length === 0)
524
+ return;
525
+ // While scrubbing (touchmove) stop the page from scrolling.
526
+ if (preventScroll && event.cancelable) {
527
+ event.preventDefault();
528
+ }
529
+ const rect = this.canvas.getBoundingClientRect();
530
+ const x = event.touches[0].clientX - rect.left;
531
+ this.updateHover(x);
532
+ }
533
+ onMouseLeave() {
534
+ this.hoverLine.style.display = 'none';
535
+ this.tooltip.style.display = 'none';
536
+ this.clearHoverLabels();
537
+ this.resetLegendValues();
538
+ if (this.hoverIndex !== -1) {
539
+ this.hoverIndex = -1;
540
+ this.draw();
541
+ }
542
+ }
543
+ handleResize() {
544
+ this.setupCanvas();
545
+ this.draw();
546
+ }
547
+ renderLegend() {
548
+ this.chartLegend.innerHTML = '';
549
+ this.legendValueEls = [];
550
+ this.lines.forEach((line) => {
551
+ const item = document.createElement('div');
552
+ item.className = 'legend-item';
553
+ const dot = document.createElement('span');
554
+ dot.className = 'legend-dot';
555
+ dot.style.backgroundColor = line.color;
556
+ const name = document.createElement('span');
557
+ name.className = 'legend-text';
558
+ name.textContent = line.name;
559
+ const value = document.createElement('span');
560
+ value.className = 'legend-value';
561
+ value.textContent = `${line.percentage.toFixed(1)}%`;
562
+ item.appendChild(dot);
563
+ item.appendChild(name);
564
+ item.appendChild(value);
565
+ this.chartLegend.appendChild(item);
566
+ this.legendValueEls.push(value);
567
+ });
568
+ }
569
+ /** Update legend values to those of the hovered point. */
570
+ updateLegendValues(index) {
571
+ this.lines.forEach((line, i) => {
572
+ const el = this.legendValueEls[i];
573
+ if (!el)
574
+ return;
575
+ const hoverPoint = this.resolveHoverPoint(line, index);
576
+ const value = hoverPoint ? hoverPoint.value : line.percentage;
577
+ el.textContent = `${value.toFixed(1)}%`;
578
+ });
579
+ }
580
+ /** Restore legend values to the default (current) percentages. */
581
+ resetLegendValues() {
582
+ this.lines.forEach((line, i) => {
583
+ const el = this.legendValueEls[i];
584
+ if (el) {
585
+ el.textContent = `${line.percentage.toFixed(1)}%`;
586
+ }
587
+ });
588
+ }
589
+ static initialize() {
590
+ if (typeof customElements !== 'undefined' &&
591
+ !customElements.get('multi-line-chart')) {
592
+ customElements.define('multi-line-chart', MultiLineChart);
593
+ }
594
+ }
595
+ }
596
+ exports.MultiLineChart = MultiLineChart;
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "multiline-chart",
3
+ "version": "1.0.1",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "node scripts/inject-styles.js && tsc"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [],
14
+ "author": "",
15
+ "license": "ISC",
16
+ "description": "",
17
+ "devDependencies": {
18
+ "typescript": "^6.0.3"
19
+ }
20
+ }