@granite-elements/granite-timeline 2.0.0 → 3.0.0

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
- "description": "A timeline rendering element using d3 and d3-timelines plugin",
2
+ "name": "@granite-elements/granite-timeline",
3
+ "version": "3.0.0",
4
+ "description": "A timeline rendering web component using Lit and d3",
3
5
  "keywords": [
4
6
  "web-component",
5
7
  "web-components",
6
- "polymer",
8
+ "lit",
7
9
  "lostinbrittany",
8
10
  "d3",
9
- "d3-timelines",
10
11
  "timeline"
11
12
  ],
12
13
  "homepage": "https://github.com/LostInBrittany/granite-timeline",
@@ -14,26 +15,42 @@
14
15
  "type": "git",
15
16
  "url": "https://github.com/LostInBrittany/granite-timeline"
16
17
  },
17
- "name": "@granite-elements/granite-timeline",
18
- "version": "2.0.0",
19
- "resolutions": {
20
- "inherits": "2.0.3",
21
- "samsam": "1.1.3",
22
- "supports-color": "3.1.2",
23
- "type-detect": "1.0.0",
24
- "@webcomponents/webcomponentsjs": "2.0.0-beta.2"
25
- },
26
- "main": "granite-timeline.js",
27
18
  "author": "Horacio Gonzalez <horacio.gonzalez@gmail.com>",
28
19
  "license": "MIT",
20
+ "type": "module",
21
+ "main": "granite-timeline.js",
22
+ "module": "granite-timeline.js",
23
+ "exports": {
24
+ ".": "./granite-timeline.js",
25
+ "./granite-timeline.js": "./granite-timeline.js",
26
+ "./src/timeline-renderer.js": "./src/timeline-renderer.js"
27
+ },
28
+ "files": [
29
+ "granite-timeline.js",
30
+ "src/",
31
+ "LICENSE.md",
32
+ "README.md",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "scripts": {
36
+ "dev": "vite",
37
+ "build:demo": "vite build",
38
+ "lint": "eslint ."
39
+ },
29
40
  "dependencies": {
30
- "@granite-elements/granite-js-dependencies-grabber": "^2.0.0",
31
- "@polymer/polymer": "^3.0.0",
32
- "d3-timelines": "lostinbrittany/d3-timeline.git#8786143466f4fd8a2ebe662a4c51967ff5a18141"
41
+ "d3-axis": "^3.0.0",
42
+ "d3-scale": "^4.0.2",
43
+ "d3-scale-chromatic": "^3.1.0",
44
+ "d3-selection": "^3.0.0",
45
+ "d3-time": "^3.1.0",
46
+ "d3-time-format": "^4.1.0",
47
+ "d3-zoom": "^3.0.0",
48
+ "lit": "^3.2.0"
33
49
  },
34
50
  "devDependencies": {
35
- "@polymer/iron-demo-helpers": "^3.0.0-pre.18",
36
- "wct-browser-legacy": "^0.0.1-pre.11",
37
- "@webcomponents/webcomponentsjs": "^2.0.0"
51
+ "@eslint/js": "^9.0.0",
52
+ "eslint": "^9.0.0",
53
+ "globals": "^15.0.0",
54
+ "vite": "^6.0.0"
38
55
  }
39
56
  }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Pure d3 v7 timeline renderer for `granite-timeline`.
3
+ *
4
+ * This module has no dependency on Lit or on the custom element: it renders
5
+ * a timeline SVG into any container element from a data array and a flat
6
+ * options object. It is a modern reimplementation of the rendering features
7
+ * of the (abandoned) `d3-timelines` plugin that granite-timeline v2 wrapped.
8
+ *
9
+ * Data format (unchanged from v2):
10
+ * ```
11
+ * [
12
+ * {
13
+ * label: 'series label', // optional, shown when stacked
14
+ * myColorProperty: 'apple', // optional, see colorsProperty
15
+ * times: [
16
+ * {
17
+ * starting_time: 1355752800000, // ms epoch
18
+ * ending_time: 1355759900000, // ms epoch
19
+ * label: 'bar label', // optional
20
+ * color: '#6b0000', // optional, overrides everything
21
+ * },
22
+ * ],
23
+ * },
24
+ * ]
25
+ * ```
26
+ */
27
+
28
+ import { select, pointer } from 'd3-selection';
29
+ import { scaleTime, scaleOrdinal } from 'd3-scale';
30
+ import { axisBottom, axisTop } from 'd3-axis';
31
+ import { timeFormat } from 'd3-time-format';
32
+ import { schemeCategory10 } from 'd3-scale-chromatic';
33
+ import { zoom as d3zoom } from 'd3-zoom';
34
+
35
+ /**
36
+ * Default rendering options.
37
+ */
38
+ export const TIMELINE_DEFAULTS = {
39
+ itemHeight: 20,
40
+ itemMargin: 5,
41
+ margin: { top: 30, right: 30, bottom: 30, left: 30 },
42
+ tickSize: 6,
43
+ maxZoomScale: 1024,
44
+ todayFormat: {
45
+ marginTop: 25,
46
+ marginBottom: 0,
47
+ width: 2,
48
+ color: 'rgb(245, 157, 0)',
49
+ },
50
+ };
51
+
52
+ /** Counter used to generate unique clip-path ids inside a document. */
53
+ let clipIdCounter = 0;
54
+
55
+ /**
56
+ * Computes the time domain of the chart.
57
+ *
58
+ * Uses `beginning`/`ending` when provided, otherwise scans every time entry
59
+ * for the smallest starting_time and the largest ending_time.
60
+ *
61
+ * @param {Array} data the timeline data array
62
+ * @param {Date|undefined} beginning explicit domain start
63
+ * @param {Date|undefined} ending explicit domain end
64
+ * @return {[Date, Date]|null} the domain, or null if it cannot be computed
65
+ */
66
+ export function computeDomain(data, beginning, ending) {
67
+ let min = beginning ? beginning.getTime() : Infinity;
68
+ let max = ending ? ending.getTime() : -Infinity;
69
+
70
+ if (!beginning || !ending) {
71
+ for (const series of data) {
72
+ for (const time of series.times || []) {
73
+ if (!beginning && time.starting_time < min) {
74
+ min = time.starting_time;
75
+ }
76
+ if (!ending) {
77
+ const end = time.ending_time !== undefined ? time.ending_time : time.starting_time;
78
+ if (end > max) {
79
+ max = end;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
87
+ return null;
88
+ }
89
+ return [new Date(min), new Date(max)];
90
+ }
91
+
92
+ /**
93
+ * Flattens the data array into one bar record per time entry.
94
+ *
95
+ * Row semantics match the original d3-timeline plugin: when `stack` is true
96
+ * each series gets its own row, otherwise everything overlaps on row 0.
97
+ *
98
+ * @param {Array} data the timeline data array
99
+ * @param {boolean} stack whether series are stacked on separate rows
100
+ * @return {Array} flat array of {time, timeIndex, series, seriesIndex, rowIndex}
101
+ */
102
+ export function flattenData(data, stack) {
103
+ const flat = [];
104
+ data.forEach((series, seriesIndex) => {
105
+ (series.times || []).forEach((time, timeIndex) => {
106
+ flat.push({
107
+ time,
108
+ timeIndex,
109
+ series,
110
+ seriesIndex,
111
+ rowIndex: stack ? seriesIndex : 0,
112
+ });
113
+ });
114
+ });
115
+ return flat;
116
+ }
117
+
118
+ /**
119
+ * Resolves the fill color of a bar, in the exact priority order of v2:
120
+ * time.color → colorScale(time[colorsProperty]) → colorScale(series[colorsProperty])
121
+ * → colorScale(seriesIndex).
122
+ *
123
+ * @param {Object} record a flattened bar record
124
+ * @param {Function} colorScale the ordinal color scale
125
+ * @param {string|undefined} colorsProperty the property name mapped to the scale
126
+ * @return {string} a CSS color
127
+ */
128
+ function resolveColor({ time, series, seriesIndex }, colorScale, colorsProperty) {
129
+ if (time.color) {
130
+ return time.color;
131
+ }
132
+ if (colorsProperty) {
133
+ if (time[colorsProperty] !== undefined) {
134
+ return colorScale(time[colorsProperty]);
135
+ }
136
+ if (series[colorsProperty] !== undefined) {
137
+ return colorScale(series[colorsProperty]);
138
+ }
139
+ }
140
+ return colorScale(seriesIndex);
141
+ }
142
+
143
+ /**
144
+ * Builds the configured time axis.
145
+ *
146
+ * Tick configuration priority: tickValues → tickTime (+ tickInterval) → numTicks.
147
+ * `tickFormat` accepts either a function or a d3 time-format specifier string.
148
+ *
149
+ * @param {Function} xScale the time scale
150
+ * @param {Object} options the normalized options
151
+ * @return {Function} a configured d3 axis
152
+ */
153
+ function buildAxis(xScale, options) {
154
+ const axis = (options.axisTop ? axisTop : axisBottom)(xScale);
155
+
156
+ if (options.tickValues) {
157
+ axis.tickValues(options.tickValues.map((v) => (v instanceof Date ? v : new Date(v))));
158
+ } else if (options.tickTime) {
159
+ const interval = options.tickInterval > 1
160
+ ? options.tickTime.every(options.tickInterval)
161
+ : options.tickTime;
162
+ if (interval) {
163
+ axis.ticks(interval);
164
+ }
165
+ } else if (options.numTicks) {
166
+ axis.ticks(options.numTicks);
167
+ }
168
+
169
+ const format = options.tickFormat || '%I %p';
170
+ axis.tickFormat(typeof format === 'function' ? format : timeFormat(format));
171
+
172
+ axis.tickSize(options.tickSize !== undefined ? options.tickSize : TIMELINE_DEFAULTS.tickSize);
173
+
174
+ return axis;
175
+ }
176
+
177
+ /**
178
+ * Renders the timeline into `containerEl`, replacing any previous svg.
179
+ *
180
+ * When `options.axisZoom` is set, the time axis can be zoomed with
181
+ * Ctrl/⌘ + mouse wheel (or trackpad pinch), panned by dragging, and zoomed in
182
+ * by double-clicking. `options.onZoom(transform, [start, end])` is called on
183
+ * every user zoom/pan, and a previously saved transform can be restored by
184
+ * passing it back as `options.zoomTransform`.
185
+ *
186
+ * @param {Element} containerEl the element to render into
187
+ * @param {Array} data the timeline data array
188
+ * @param {Object} options normalized options, see granite-timeline.js _buildOptions()
189
+ * @return {{svgNode: SVGElement|null, scale: Function|null}}
190
+ */
191
+ export function renderTimeline(containerEl, data, options = {}) {
192
+ select(containerEl).selectAll('svg').remove();
193
+
194
+ const itemHeight = options.itemHeight ?? TIMELINE_DEFAULTS.itemHeight;
195
+ const itemMargin = options.itemMargin ?? TIMELINE_DEFAULTS.itemMargin;
196
+ const margin = { ...TIMELINE_DEFAULTS.margin, ...options.margin };
197
+ const width = options.width || 300;
198
+
199
+ const domain = computeDomain(data || [], options.beginning, options.ending);
200
+ if (!domain) {
201
+ return { svgNode: null, scale: null };
202
+ }
203
+
204
+ const flat = flattenData(data, options.stack);
205
+ const rowCount = options.stack ? data.length : 1;
206
+ const height = options.height
207
+ || margin.top + rowCount * (itemHeight + itemMargin) + margin.bottom;
208
+
209
+ const rowY = (rowIndex) => margin.top + (itemHeight + itemMargin) * rowIndex;
210
+
211
+ const baseScale = scaleTime()
212
+ .domain(domain)
213
+ .range([margin.left, width - margin.right]);
214
+
215
+ const colorScale = options.colors || scaleOrdinal(schemeCategory10);
216
+ const labelText = (label) => (options.labelFormat ? options.labelFormat(label) : String(label));
217
+ const barEnd = (time) => (time.ending_time !== undefined ? time.ending_time : time.starting_time);
218
+
219
+ const svg = select(containerEl)
220
+ .append('svg')
221
+ .attr('width', width)
222
+ .attr('height', height)
223
+ .attr('class', 'granite-timeline');
224
+
225
+ const svgNode = svg.node();
226
+
227
+ // Row backgrounds
228
+ if (options.background) {
229
+ svg.selectAll('rect.row-background')
230
+ .data(Array.from({ length: rowCount }, (_, i) => i))
231
+ .join('rect')
232
+ .attr('class', 'row-background')
233
+ .attr('x', margin.left)
234
+ .attr('y', (i) => rowY(i) - itemMargin / 2)
235
+ .attr('width', Math.max(0, width - margin.left - margin.right))
236
+ .attr('height', itemHeight + itemMargin)
237
+ .attr('fill', options.background);
238
+ }
239
+
240
+ // Row separators (between rows)
241
+ if (options.rowSeparators && rowCount > 1) {
242
+ svg.selectAll('line.row-separator')
243
+ .data(Array.from({ length: rowCount - 1 }, (_, i) => i + 1))
244
+ .join('line')
245
+ .attr('class', 'row-separator')
246
+ .attr('x1', margin.left)
247
+ .attr('x2', width - margin.right)
248
+ .attr('y1', (i) => rowY(i) - itemMargin / 2)
249
+ .attr('y2', (i) => rowY(i) - itemMargin / 2)
250
+ .attr('stroke', options.rowSeparators)
251
+ .attr('stroke-width', 1);
252
+ }
253
+
254
+ // With zoom enabled, bars / bar labels / today line pan out of the plot
255
+ // area: clip them so they don't bleed under the row labels and margins.
256
+ let plotLayer = svg;
257
+ if (options.axisZoom) {
258
+ const clipId = `granite-timeline-clip-${++clipIdCounter}`;
259
+ svg.append('defs')
260
+ .append('clipPath')
261
+ .attr('id', clipId)
262
+ .append('rect')
263
+ .attr('x', margin.left)
264
+ .attr('y', 0)
265
+ .attr('width', Math.max(0, width - margin.left - margin.right))
266
+ .attr('height', height);
267
+ plotLayer = svg.append('g').attr('clip-path', `url(#${clipId})`);
268
+ }
269
+
270
+ // Bars (x-dependent attributes are set in applyScale)
271
+ const bars = plotLayer.selectAll('rect.timeline-bar')
272
+ .data(flat)
273
+ .join('rect')
274
+ .attr('class', (d) => `timeline-bar${d.series.class ? ` ${d.series.class}` : ''}`)
275
+ .attr('y', (d) => rowY(d.rowIndex))
276
+ .attr('height', itemHeight)
277
+ .attr('fill', (d) => resolveColor(d, colorScale, options.colorsProperty));
278
+
279
+ // Per-bar labels
280
+ const barLabels = plotLayer.selectAll('text.timeline-bar-label')
281
+ .data(flat.filter((d) => d.time.label !== undefined))
282
+ .join('text')
283
+ .attr('class', 'timeline-bar-label')
284
+ .attr('y', (d) => rowY(d.rowIndex) + itemHeight * 0.75)
285
+ .text((d) => labelText(d.time.label));
286
+
287
+ // Series row labels (meaningful when stacked)
288
+ if (options.stack) {
289
+ svg.selectAll('text.timeline-label')
290
+ .data(data.map((series, i) => ({ series, i })).filter((d) => d.series.label !== undefined))
291
+ .join('text')
292
+ .attr('class', 'timeline-label')
293
+ .attr('x', Math.max(0, margin.left - 10))
294
+ .attr('y', (d) => rowY(d.i) + itemHeight * 0.75)
295
+ .attr('text-anchor', 'end')
296
+ .text((d) => labelText(d.series.label));
297
+ }
298
+
299
+ // Today line
300
+ let todayLine = null;
301
+ const now = Date.now();
302
+ if (options.showToday && now >= domain[0].getTime() && now <= domain[1].getTime()) {
303
+ const todayFormat = { ...TIMELINE_DEFAULTS.todayFormat, ...options.todayFormat };
304
+ todayLine = plotLayer.append('line')
305
+ .attr('class', 'timeline-today')
306
+ .attr('y1', todayFormat.marginTop)
307
+ .attr('y2', height - todayFormat.marginBottom)
308
+ .attr('stroke', todayFormat.color)
309
+ .attr('stroke-width', todayFormat.width);
310
+ }
311
+
312
+ // Time axis
313
+ let axisG = null;
314
+ if (options.showTimeAxis) {
315
+ axisG = svg.append('g')
316
+ .attr('class', 'axis timeline-axis')
317
+ .attr('transform', `translate(0, ${options.axisTop ? margin.top : height - margin.bottom})`);
318
+ }
319
+
320
+ /** (Re)applies a time scale to every x-dependent part of the chart. */
321
+ const applyScale = (scale) => {
322
+ bars
323
+ .attr('x', (d) => scale(d.time.starting_time))
324
+ .attr('width', (d) => Math.max(0, scale(barEnd(d.time)) - scale(d.time.starting_time)));
325
+ barLabels.attr('x', (d) => scale(d.time.starting_time) + 5);
326
+ if (todayLine) {
327
+ todayLine.attr('x1', scale(now)).attr('x2', scale(now));
328
+ }
329
+ if (axisG) {
330
+ axisG.call(buildAxis(scale, options));
331
+ if (options.rotateTicks) {
332
+ const rotate = options.rotateTicks;
333
+ axisG.selectAll('text')
334
+ .attr('transform', `rotate(${rotate})`)
335
+ .attr('dx', rotate < 0 ? '-0.8em' : '0.8em')
336
+ .attr('dy', rotate < 0 ? '0.15em' : '0.55em')
337
+ .style('text-anchor', rotate < 0 ? 'end' : 'start');
338
+ }
339
+ }
340
+ };
341
+ applyScale(baseScale);
342
+
343
+ // Axis zoom & pan
344
+ if (options.axisZoom) {
345
+ const plotExtent = [[margin.left, 0], [width - margin.right, height]];
346
+ const zoomBehavior = d3zoom()
347
+ .scaleExtent([1, options.maxZoomScale ?? TIMELINE_DEFAULTS.maxZoomScale])
348
+ .extent(plotExtent)
349
+ .translateExtent(plotExtent)
350
+ // Plain wheel keeps scrolling the page: zooming needs Ctrl/⌘ (trackpad
351
+ // pinch gestures report ctrlKey and work out of the box).
352
+ .filter((event) => {
353
+ if (event.type === 'wheel') {
354
+ return event.ctrlKey || event.metaKey;
355
+ }
356
+ return !event.button;
357
+ })
358
+ .on('zoom', (event) => {
359
+ const scale = event.transform.rescaleX(baseScale);
360
+ applyScale(scale);
361
+ // Only report user interactions, not programmatic transform restores
362
+ if (options.onZoom && event.sourceEvent) {
363
+ options.onZoom(event.transform, scale.domain());
364
+ }
365
+ });
366
+ svg.call(zoomBehavior).style('cursor', 'grab');
367
+ if (options.zoomTransform) {
368
+ svg.call(zoomBehavior.transform, options.zoomTransform);
369
+ }
370
+ }
371
+
372
+ // Bar events
373
+ if (options.onBarEvent) {
374
+ const emit = (type) => (event, record) => {
375
+ options.onBarEvent(type, {
376
+ d: record.time,
377
+ index: record.timeIndex,
378
+ datum: record.series,
379
+ mouse: pointer(event, svgNode),
380
+ evt: event,
381
+ });
382
+ };
383
+ bars
384
+ .on('click', emit('click'))
385
+ .on('mouseover', emit('mouseover'))
386
+ .on('mouseout', emit('mouseout'))
387
+ .on('mousemove', emit('hover'));
388
+ }
389
+
390
+ return { svgNode, scale: baseScale };
391
+ }
package/.eslintrc.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "extends": ["eslint:recommended", "google"],
3
- "parserOptions": {
4
- "ecmaVersion": 6,
5
- "sourceType": "module"
6
- },
7
- "env": {
8
- "browser": true
9
- },
10
- "plugins": [
11
- "html"
12
- ],
13
- "rules": {
14
- "max-len": [
15
- "off"
16
- ],
17
- "block-spacing": "off",
18
- "brace-style": "off",
19
- "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
20
- "no-var": "off",
21
- "no-console": "off",
22
- "object-curly-spacing": "off",
23
- "require-jsdoc": "off"
24
- },
25
- "globals": {
26
- "Polymer": true
27
- }
28
- }
package/demo/index.html DELETED
@@ -1,50 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">
6
-
7
- <title>granite-timeline demo</title>
8
-
9
- <script src="../../../@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
10
-
11
- <script type="module" src="../../../@polymer/iron-demo-helpers/demo-pages-shared-styles.js"></script>
12
- <script type="module" src="../../../@polymer/iron-demo-helpers/demo-snippet.js"></script>
13
- <script type="module" src="../granite-timeline.js"></script>
14
-
15
- <!-- FIXME(polymer-modulizer):
16
- These imperative modules that innerHTML your HTML are
17
- a hacky way to be sure that any mixins in included style
18
- modules are ready before any elements that reference them are
19
- instantiated, otherwise the CSS @apply mixin polyfill won't be
20
- able to expand the underlying CSS custom properties.
21
- See: https://github.com/Polymer/polymer-modulizer/issues/154
22
- -->
23
- <script type="module">
24
- const $_documentContainer = document.createElement('template');
25
-
26
- $_documentContainer.innerHTML = `<custom-style>
27
- <style is="custom-style" include="demo-pages-shared-styles">
28
- </style>
29
- </custom-style>`;
30
-
31
- document.body.appendChild($_documentContainer.content);
32
- </script>
33
- </head>
34
- <body>
35
- <script type="module">
36
- const $_documentContainer = document.createElement('template');
37
-
38
- $_documentContainer.innerHTML = `<div class="vertical-section-container centered">
39
- <h3>Basic granite-timeline demo</h3>
40
- <demo-snippet>
41
- <template>
42
- <granite-timeline data="[{&quot;times&quot;:[{&quot;starting_time&quot;:1355752800000,&quot;ending_time&quot;:1355759900000}, {&quot;starting_time&quot;:1355767900000,&quot;ending_time&quot;:1355774400000}]},{&quot;times&quot;:[{&quot;starting_time&quot;:1355759910000,&quot;ending_time&quot;:1355761900000}]},{&quot;times&quot;:[{&quot;starting_time&quot;:1355761910000,&quot;ending_time&quot;:1355763910000}]}]" debug=""></granite-timeline>
43
- </template>
44
- </demo-snippet>
45
- </div>`;
46
-
47
- document.body.appendChild($_documentContainer.content);
48
- </script>
49
- </body>
50
- </html>