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 +170 -0
- package/dist/chart-styles.d.ts +1 -0
- package/dist/chart-styles.js +127 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +596 -0
- package/package.json +20 -0
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
|
+
`;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|