├── .nvmrc ├── .prettierignore ├── babel.config.js ├── test ├── wrapper-test.js ├── delay.js ├── tape.js ├── get_git_branch.js ├── .eslintrc.json ├── apm.js ├── milestones.js └── event.js ├── .claude ├── settings.local.json └── commands │ └── fix-github-issues.md ├── .prettierrc ├── src ├── stories │ ├── assets │ │ ├── vikings.png │ │ ├── image-formats.jpeg │ │ ├── image-formats.jpg │ │ ├── image-formats.json │ │ ├── bullet-styles.json │ │ ├── styles.json │ │ ├── ultima-series.json │ │ ├── milestones-events.json │ │ ├── issue-80.json │ │ ├── covid19.json │ │ ├── lotr.json │ │ ├── milestones-with-ids.json │ │ ├── vikings.json │ │ ├── milestones.json │ │ └── os-category-labels.json │ ├── example-styles.less │ ├── README.mdx │ ├── example-08-styles.stories.js │ ├── example-04-covid19.stories.js │ ├── example-02-vikings.stories.js │ ├── example-06-ultima-series.stories.js │ ├── example-07-ultima-series-covers.stories.js │ ├── example-03-os-category-labels.stories.js │ ├── example-10-image-formats.stories.js │ ├── example-15-bullet-styles.stories.js │ ├── example-00-milestones.stories.js │ ├── example-09-custom-ids.stories.js │ ├── example-13-issue-80.stories.js │ ├── example-01-milestones-events.stories.js │ ├── example-11-ordinal-scale.stories.js │ ├── example-05-lotr.stories.js │ ├── example-12-ordinal-scale-categories.stories.js │ └── milestones.js ├── _get_attribute.js ├── .eslintrc.json ├── _is_above.js ├── _aggregate_formats.js ├── _get_attribute.test.js ├── _api.js ├── _aggregate_formats.test.js ├── _api.test.js ├── _get_next_group_height.test.js ├── _time_format.js ├── _is_above.test.js ├── _defaults.js ├── _get_next_group_height.js ├── _css.js ├── _time_parse.js ├── _get_available_width.test.js ├── _transform.js ├── _get_available_width.js ├── styles │ └── styles.less ├── main.test.js ├── _optimize.js └── main.js ├── .gitignore ├── .storybook ├── preview.js ├── manager.js └── main.js ├── jest.config.js ├── .npmignore ├── .changeset ├── config.json └── README.md ├── .eslintrc.json ├── LICENSE.md ├── rollup.config.js ├── DEVELOPMENT.md ├── .github ├── workflows │ ├── ci.yml │ └── release.yml └── dependabot.yml ├── karma.config.js ├── CONTRIBUTING.md ├── CHANGELOG.md ├── package.json ├── CLAUDE.md ├── docs └── RELEASE_PROCESS.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | example 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { presets: ['@babel/preset-env'] }; 2 | -------------------------------------------------------------------------------- /test/wrapper-test.js: -------------------------------------------------------------------------------- 1 | import './milestones.js'; 2 | import './event.js'; 3 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "enableAllProjectMcpServers": false 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /src/stories/assets/vikings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walterra/d3-milestones/HEAD/src/stories/assets/vikings.png -------------------------------------------------------------------------------- /test/delay.js: -------------------------------------------------------------------------------- 1 | export function delay(time) { 2 | return new Promise((resolve) => setTimeout(resolve, time)); 3 | } 4 | -------------------------------------------------------------------------------- /src/stories/assets/image-formats.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walterra/d3-milestones/HEAD/src/stories/assets/image-formats.jpeg -------------------------------------------------------------------------------- /src/stories/assets/image-formats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walterra/d3-milestones/HEAD/src/stories/assets/image-formats.jpg -------------------------------------------------------------------------------- /src/_get_attribute.js: -------------------------------------------------------------------------------- 1 | export function getAttribute(d, attribute) { 2 | return parseInt(d.style[attribute].replace('px', ''), 10); 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | *.iml 4 | *.log 5 | node_modules/ 6 | npm-debug.log 7 | .vscode 8 | .source.*.html 9 | build/ 10 | storybook-static 11 | -------------------------------------------------------------------------------- /test/tape.js: -------------------------------------------------------------------------------- 1 | var tape = require('tape'); 2 | var _test = require('tape-promise').default; // <---- notice 'default' 3 | module.exports = _test(tape); // decorate tape 4 | -------------------------------------------------------------------------------- /src/stories/example-styles.less: -------------------------------------------------------------------------------- 1 | .d3Milestones { 2 | font-family: sans-serif; 3 | } 4 | 5 | .timeline { 6 | height: 900px; 7 | width: 100%; 8 | padding: 0 0 20px 0; 9 | } 10 | -------------------------------------------------------------------------------- /test/get_git_branch.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | 3 | const getBranch = () => 4 | execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); 5 | 6 | module.exports = { getBranch }; 7 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "../.eslintrc.json", "plugin:jest/recommended" ], 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "jest/globals": true, 7 | "node": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | controls: { 3 | matchers: { 4 | color: /(background|color)$/i, 5 | date: /Date$/, 6 | }, 7 | }, 8 | }; 9 | export const tags = ["autodocs", "autodocs"]; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.js?$': require.resolve('babel-jest'), 4 | }, 5 | transformIgnorePatterns: [ 6 | '/node_modules/(?!d3|d3-array|internmap|delaunator|robust-predicates)', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/_is_above.js: -------------------------------------------------------------------------------- 1 | export function isAbove(i, distribution) { 2 | let above = i % 2; 3 | if (distribution === 'top') { 4 | above = true; 5 | } else if (distribution === 'bottom') { 6 | above = false; 7 | } 8 | return above > 0; 9 | } 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | *.iml 4 | *.log 5 | node_modules/ 6 | npm-debug.log 7 | .vscode 8 | yarn.lock 9 | .source.*.html 10 | build/*.zip 11 | example/ 12 | test/ 13 | karma.config.js 14 | rollup.config.js 15 | .storybook 16 | storybook-static 17 | -------------------------------------------------------------------------------- /src/stories/README.mdx: -------------------------------------------------------------------------------- 1 | import { Markdown } from '@storybook/blocks'; 2 | import { Meta } from '@storybook/blocks'; 3 | import ReadMe from '../../README.md?raw'; 4 | 5 | 6 | 7 | 8 | {ReadMe} 9 | 10 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "env": { 4 | "browser": true 5 | }, 6 | "globals": { 7 | "elasticApm": "readonly", 8 | "require": "readonly", 9 | "APM_GIT_BRANCH": "readonly", 10 | "APM_SERVER": "readonly" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import { create } from '@storybook/theming'; 3 | 4 | import p from '../package.json'; 5 | 6 | addons.setConfig({ 7 | theme: create({ 8 | base: 'light', 9 | brandTitle: `d3-milestones v${p.version}`, 10 | }), 11 | }); 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/_aggregate_formats.js: -------------------------------------------------------------------------------- 1 | // second, minute, hour, day, week, month, quarter, year 2 | export const aggregateFormats = { 3 | second: '%Y-%m-%d %H:%M:%S', 4 | minute: '%Y-%m-%d %H:%M', 5 | hour: '%Y-%m-%d %H:00', 6 | day: '%Y-%m-%d', 7 | week: '%Y week %W', 8 | month: '%Y-%m', 9 | quarter: '%Y-Q%Q', 10 | year: '%Y', 11 | }; 12 | -------------------------------------------------------------------------------- /src/_get_attribute.test.js: -------------------------------------------------------------------------------- 1 | import { getAttribute } from './_get_attribute'; 2 | 3 | describe('getAttribute', () => { 4 | it('should get the integer value', () => { 5 | const mockElement = { 6 | style: { 7 | width: '10px', 8 | }, 9 | }; 10 | 11 | expect(getAttribute(mockElement, 'width')).toBe(10); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/_api.js: -------------------------------------------------------------------------------- 1 | export default function (methods) { 2 | function methodChainer(wrapper, method) { 3 | return (d) => { 4 | method(d); 5 | return wrapper; 6 | }; 7 | } 8 | 9 | return Object.keys(methods).reduce((API, methodName) => { 10 | API[methodName] = methodChainer(API, methods[methodName]); 11 | return API; 12 | }, {}); 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | "ENV": true 9 | }, 10 | "extends": [ "eslint:recommended", "plugin:prettier/recommended" ], 11 | "parserOptions": { 12 | "sourceType": "module" 13 | }, 14 | "plugins": ["jest", "prettier"], 15 | "rules": { 16 | "prettier/prettier": ["error"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/_aggregate_formats.test.js: -------------------------------------------------------------------------------- 1 | import { aggregateFormats } from './_aggregate_formats'; 2 | 3 | describe('aggregateFormats', () => { 4 | it('should match', () => { 5 | expect(Object.keys(aggregateFormats)).toStrictEqual([ 6 | 'second', 7 | 'minute', 8 | 'hour', 9 | 'day', 10 | 'week', 11 | 'month', 12 | 'quarter', 13 | 'year', 14 | ]); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/_api.test.js: -------------------------------------------------------------------------------- 1 | import api from './_api'; 2 | 3 | describe('api', () => { 4 | it('should chain methods', () => { 5 | let value = 0; 6 | const increase = (add) => { 7 | value = value + add; 8 | }; 9 | const square = () => { 10 | value = value * value; 11 | }; 12 | 13 | const m = api({ increase, square }); 14 | m.increase(1).increase(2).square(); 15 | 16 | expect(value).toBe(9); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/_get_next_group_height.test.js: -------------------------------------------------------------------------------- 1 | import { getNextGroupHeight } from './_get_next_group_height'; 2 | 3 | describe('getNextGroupHeight', () => { 4 | it('should get the next group height', () => { 5 | const nodes = [[{ offsetHeight: 0 }], [{ offsetHeight: 100 }]]; 6 | 7 | const nextGroupHeight = getNextGroupHeight( 8 | 0, 9 | 1, 10 | nodes, 11 | 'offsetHeight', 12 | 'horizontal' 13 | ); 14 | 15 | expect(nextGroupHeight).toBe(103); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/stories/example-08-styles.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/styles.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => createMilestones('Styles', undefined, args); 10 | 11 | export const Styles = Template.bind({}); 12 | Styles.args = { 13 | aggregateBy: 'year', 14 | optimize: true, 15 | parseTime: '%Y', 16 | mapping: { 17 | timestamp: 'year', 18 | text: 'text', 19 | }, 20 | data, 21 | }; 22 | -------------------------------------------------------------------------------- /src/stories/example-04-covid19.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/covid19.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => createMilestones('COVID19 Quotes', undefined, args); 10 | 11 | export const COVID19 = Template.bind({}); 12 | COVID19.args = { 13 | optimize: true, 14 | aggregateBy: 'day', 15 | parseTime: '%Y-%m-%d', 16 | mapping: { 17 | timestamp: 'date', 18 | text: 'title', 19 | }, 20 | data, 21 | }; 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017-2018 Elasticsearch BV 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 8 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import eslint from '@rollup/plugin-eslint'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | 5 | export default { 6 | input: 'src/main.js', 7 | output: { 8 | file: 'build/d3-milestones.js', 9 | format: 'umd', 10 | name: 'milestones', 11 | sourcemap: true, 12 | sourcemapFile: 'build/d3-milestones.js', 13 | }, 14 | plugins: [ 15 | eslint({ 16 | exclude: ['src/styles/**'], 17 | }), 18 | nodeResolve(), 19 | babel({ babelHelpers: 'bundled' }), 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/_time_format.js: -------------------------------------------------------------------------------- 1 | import { timeFormat as d3TimeFormat } from 'd3-time-format'; 2 | 3 | import { aggregateFormats } from './_aggregate_formats'; 4 | 5 | export function timeFormat(f) { 6 | if (f === '%Y-Q%Q') { 7 | const quarterFormatter = d3TimeFormat(aggregateFormats.month); 8 | return (d) => { 9 | const formattedDate = quarterFormatter(d); 10 | const month = formattedDate.split('-')[1]; 11 | const quarter = Math.ceil(parseInt(month) / 3); 12 | return formattedDate.split('-')[0] + '-Q' + quarter; 13 | }; 14 | } 15 | return d3TimeFormat(f); 16 | } 17 | -------------------------------------------------------------------------------- /src/stories/example-02-vikings.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/vikings.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones('The Viking Timeline', undefined, args); 11 | 12 | export const Vikings = Template.bind({}); 13 | Vikings.args = { 14 | aggregateBy: 'year', 15 | optimize: true, 16 | parseTime: '%Y', 17 | mapping: { 18 | timestamp: 'year', 19 | text: 'title', 20 | id: 'id', 21 | }, 22 | data, 23 | }; 24 | -------------------------------------------------------------------------------- /src/_is_above.test.js: -------------------------------------------------------------------------------- 1 | import { isAbove } from './_is_above'; 2 | 3 | describe('isAbove', () => { 4 | it('should return if the item is above or below', () => { 5 | expect(isAbove(0)).toBe(false); 6 | expect(isAbove(1)).toBe(true); 7 | expect(isAbove(2)).toBe(false); 8 | 9 | expect(isAbove(0, 'top')).toBe(true); 10 | expect(isAbove(1, 'top')).toBe(true); 11 | expect(isAbove(2, 'top')).toBe(true); 12 | 13 | expect(isAbove(0, 'bottom')).toBe(false); 14 | expect(isAbove(1, 'bottom')).toBe(false); 15 | expect(isAbove(2, 'bottom')).toBe(false); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/stories/example-06-ultima-series.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | // from https://www.mobygames.com/game-group/ultima-series/offset,0/so,1d/ 3 | import data from './assets/ultima-series.json'; 4 | 5 | export default { 6 | title: 'd3-milestones', 7 | argTypes, 8 | }; 9 | 10 | const Template = (args) => createMilestones('Ultima series', undefined, args); 11 | 12 | export const UltimaSeries = Template.bind({}); 13 | UltimaSeries.args = { 14 | aggregateBy: 'year', 15 | optimize: true, 16 | parseTime: '%Y', 17 | mapping: { 18 | timestamp: 'year', 19 | text: 'title', 20 | }, 21 | data, 22 | }; 23 | -------------------------------------------------------------------------------- /src/stories/example-07-ultima-series-covers.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | // from https://www.mobygames.com/game-group/ultima-series/offset,0/so,1d/ 3 | import data from './assets/ultima-series.json'; 4 | 5 | export default { 6 | title: 'd3-milestones', 7 | argTypes, 8 | }; 9 | 10 | const Template = (args) => createMilestones('Ultima series', undefined, args); 11 | 12 | export const UltimaSeriesCovers = Template.bind({}); 13 | UltimaSeriesCovers.args = { 14 | aggregateBy: 'year', 15 | optimize: true, 16 | parseTime: '%Y', 17 | mapping: { 18 | timestamp: 'year', 19 | text: 'cover', 20 | }, 21 | data, 22 | }; 23 | -------------------------------------------------------------------------------- /src/stories/example-03-os-category-labels.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/os-category-labels.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones('Windows / Mac OS Timelines with Labels', undefined, args); 11 | 12 | export const OsCategoryLabels = Template.bind({}); 13 | OsCategoryLabels.args = { 14 | optimize: true, 15 | aggregateBy: 'year', 16 | parseTime: '%Y', 17 | mapping: { 18 | category: 'system', 19 | entries: 'versions', 20 | timestamp: 'year', 21 | text: 'title', 22 | }, 23 | data, 24 | }; 25 | -------------------------------------------------------------------------------- /.claude/commands/fix-github-issues.md: -------------------------------------------------------------------------------- 1 | Please analyze and fix the GitHub issue: $ARGUMENTS. 2 | 3 | Follow these steps: 4 | 5 | 1. Use `gh issue view` to get the issue details 6 | 2. Understand the problem described in the issue 7 | 3. Search the codebase for relevant files 8 | 4. Make sure you are on `main`, then create a new description branch including the issue ID 9 | 5. Implement the necessary changes to fix the issue 10 | 6. Write and run tests to verify the fix 11 | 7. Ensure code passes linting and type checking 12 | 8. Create a descriptive commit message 13 | 9. Push the feature branch and create a PR using `gh` 14 | 15 | Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks. -------------------------------------------------------------------------------- /src/stories/example-10-image-formats.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/image-formats.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones( 11 | 'Image formats', 12 | `This example demonstrates the use of different image formats for milestone elements.`, 13 | args 14 | ); 15 | 16 | export const ImageFormats = Template.bind({}); 17 | ImageFormats.args = { 18 | optimize: true, 19 | aggregateBy: 'year', 20 | parseTime: '%Y', 21 | mapping: { 22 | timestamp: 'year', 23 | text: 'img', 24 | }, 25 | data, 26 | }; 27 | -------------------------------------------------------------------------------- /src/stories/example-15-bullet-styles.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/bullet-styles.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones( 11 | 'Bullet Styles', 12 | 'Custom bullet colors, sizes, and borders using bulletStyle', 13 | args 14 | ); 15 | 16 | export const BulletStyles = Template.bind({}); 17 | BulletStyles.args = { 18 | aggregateBy: 'year', 19 | optimize: true, 20 | parseTime: '%Y', 21 | mapping: { 22 | timestamp: 'year', 23 | text: 'text', 24 | bulletStyle: 'bulletStyle', 25 | }, 26 | data, 27 | }; 28 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development Notes 2 | 3 | - For every update, don't forget to add a note in [CHANGELOG.md](./CHANGELOG.md). 4 | - How to do a release: 5 | - `git checkout main` 6 | - Check if [CHANGELOG.md](./CHANGELOG.md) is up to date. 7 | - Update the version number in [package.json](./package.json). 8 | - Update milestones example in `/src/stories/assets/milestones.json`. 9 | - `git commit -m "Bump version to . ..."` 10 | - `git tag ` 11 | - `yarn build-storybook` 12 | - `yarn deploy-storybook` 13 | - `git push origin main` 14 | - `git push --tags` 15 | - `npm publish` 16 | - Add version changelog to GitHub release https://github.com/walterra/d3-milestones/tags 17 | -------------------------------------------------------------------------------- /src/_defaults.js: -------------------------------------------------------------------------------- 1 | export const DEFAULTS = { 2 | DISTRIBUTION: 'top-bottom', 3 | OPTIMIZE: false, 4 | ORIENTATION: 'horizontal', 5 | SCALE_TYPE: 'time', 6 | MAPPING: { 7 | category: undefined, 8 | entries: undefined, 9 | timestamp: 'timestamp', // Used only for time based scales 10 | value: 'value', // Used only for ordinal scale values 11 | text: 'text', 12 | url: 'url', 13 | id: 'id', 14 | textStyle: 'textStyle', 15 | titleStyle: 'titleStyle', 16 | categoryStyle: 'categoryStyle', 17 | bulletStyle: 'bulletStyle', 18 | }, 19 | LABEL_FORMAT: '%Y-%m-%d %H:%M', 20 | USE_LABELS: true, 21 | AGGREGATE_BY: 'minute', 22 | AUTO_RESIZE: true, 23 | URL_TARGET: '_self', 24 | }; 25 | -------------------------------------------------------------------------------- /src/_get_next_group_height.js: -------------------------------------------------------------------------------- 1 | const getNextGroup = (orientation, nodes, index, nextCheck) => { 2 | const nextGroup = 3 | orientation === 'horizontal' 4 | ? nodes[index + nextCheck] 5 | : nodes[index - nextCheck]; 6 | 7 | return nextGroup; 8 | }; 9 | 10 | export const getNextGroupHeight = ( 11 | index, 12 | nextCheck, 13 | nodes, 14 | offsetAttribute, 15 | orientation 16 | ) => { 17 | // get the height of the next group 18 | const defaultPadding = 3; 19 | 20 | const nextGroup = getNextGroup(orientation, nodes, index, nextCheck); 21 | 22 | let nextGroupHeight = 0; 23 | 24 | if (typeof nextGroup !== 'undefined') { 25 | nextGroupHeight = nextGroup[0][offsetAttribute] + defaultPadding; 26 | } 27 | 28 | return nextGroupHeight; 29 | }; 30 | -------------------------------------------------------------------------------- /src/stories/example-00-milestones.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/milestones.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones( 11 | 'Version Milestones', 12 | `The chart is responsive, try resizing the browser window. Use the storybook's options to trigger examples of the chart's features`, 13 | args 14 | ); 15 | 16 | export const MilestonesReleases = Template.bind({}); 17 | MilestonesReleases.args = { 18 | optimize: true, 19 | aggregateBy: 'day', 20 | mapping: { 21 | timestamp: 'timestamp', 22 | text: 'detail', 23 | url: 'giturl', 24 | }, 25 | urlTarget: '_blank', 26 | data, 27 | }; 28 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | This folder is used by [@changesets/cli](https://github.com/changesets/changesets) to manage versioning and changelogs. 4 | 5 | ## Adding a changeset 6 | 7 | When you make a change that should be released, run: 8 | 9 | ```bash 10 | yarn changeset 11 | ``` 12 | 13 | This will prompt you to: 14 | 1. Select the type of change (major/minor/patch) 15 | 2. Write a summary of the change 16 | 17 | The changeset file will be committed with your PR. 18 | 19 | ## Release process 20 | 21 | 1. When PRs with changesets are merged to `main`, a "Release" PR is automatically created/updated 22 | 2. The Release PR collects all pending changesets and updates the version/changelog 23 | 3. When the Release PR is merged, the package is automatically published to npm 24 | -------------------------------------------------------------------------------- /src/_css.js: -------------------------------------------------------------------------------- 1 | export const cssPrefix = 'milestones'; 2 | export const cssCategoryClass = cssPrefix + '__category_label'; 3 | export const cssHorizontalLineClass = cssPrefix + '__horizontal_line'; 4 | export const cssVerticalLineClass = cssPrefix + '__vertical_line'; 5 | export const cssGroupClass = cssPrefix + '__group'; 6 | export const cssBulletClass = cssGroupClass + '__bullet'; 7 | export const cssLabelClass = cssGroupClass + '__label'; 8 | export const cssLastClass = cssLabelClass + '-last'; 9 | export const cssAboveClass = cssLabelClass + '-above'; 10 | export const cssTextClass = cssLabelClass + '__text'; 11 | export const cssTitleClass = cssTextClass + '__title'; 12 | export const cssEventClass = cssTextClass + '__event'; 13 | export const cssEventHoverClass = cssEventClass + '--hover'; 14 | -------------------------------------------------------------------------------- /src/stories/assets/image-formats.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "JPG", 4 | "img": "/image-formats.jpg", 5 | "year": 1992 6 | }, 7 | { 8 | "title": "JPEG", 9 | "img": "image-formats.jpeg", 10 | "year": 1992 11 | }, 12 | { 13 | "title": "PNG", 14 | "img": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/560px-PNG_transparency_demonstration_1.png", 15 | "year": 1994 16 | }, 17 | { 18 | "title": "GIF", 19 | "img": "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif", 20 | "year": 1987 21 | }, 22 | { 23 | "title": "WEBP", 24 | "img": "https://www.gstatic.com/webp/gallery/1.sm.webp", 25 | "year": 2010 26 | } 27 | ] 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/stories/example-09-custom-ids.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/milestones-with-ids.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones( 11 | 'Custom ID Attributes', 12 | `This example demonstrates the use of custom HTML ID attributes for milestone elements. Each milestone element has a unique ID attribute that can be used for direct DOM access or styling.`, 13 | args 14 | ); 15 | 16 | export const CustomIds = Template.bind({}); 17 | CustomIds.args = { 18 | optimize: true, 19 | aggregateBy: 'day', 20 | mapping: { 21 | timestamp: 'timestamp', 22 | text: 'detail', 23 | url: 'giturl', 24 | id: 'customId', 25 | }, 26 | urlTarget: '_blank', 27 | data, 28 | }; 29 | -------------------------------------------------------------------------------- /src/stories/assets/bullet-styles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text": "Default Bullet", 4 | "year": 1980 5 | }, 6 | { 7 | "text": "Extra Large Red Bullet", 8 | "year": 1990, 9 | "bulletStyle": { "background-color": "#E12800", "border-color": "#E12800", "padding": "10px", "border-width": "5px" } 10 | }, 11 | { 12 | "text": "Large Green Border", 13 | "year": 2000, 14 | "bulletStyle": { "border-color": "#2BFD00", "border-width": "8px", "padding": "6px" } 15 | }, 16 | { 17 | "text": "Medium Blue", 18 | "year": 2010, 19 | "bulletStyle": { "background-color": "#5BC6FF", "border-color": "#0078D4", "padding": "4px" } 20 | }, 21 | { 22 | "text": "Tiny Yellow Bullet", 23 | "year": 2020, 24 | "bulletStyle": { "background-color": "#FFD700", "border-color": "#FFA500", "padding": "0.5px", "border-width": "1px" } 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /test/apm.js: -------------------------------------------------------------------------------- 1 | const apm = 2 | typeof APM_SERVER === 'string' 3 | ? elasticApm.init({ 4 | serviceName: 'd3-milestones-karma-service', 5 | serverUrl: APM_SERVER, 6 | transactionSampleRate: 1, 7 | pageLoadTransactionName: 'd3-milestones-karma-page', 8 | }) 9 | : undefined; 10 | 11 | export function startTransaction(spanName) { 12 | let transaction = undefined; 13 | let span = undefined; 14 | 15 | if (apm) { 16 | transaction = apm.startTransaction('d3-milestones/karma', 'custom'); 17 | transaction.addLabels({ 'd3-milestones-layout': APM_GIT_BRANCH }); 18 | span = transaction.startSpan(spanName, 'render-chart'); 19 | span.addLabels({ 'd3-milestones-layout': APM_GIT_BRANCH }); 20 | } 21 | 22 | return { 23 | endTransaction: () => { 24 | if (transaction && span) { 25 | span.end(); 26 | transaction.end(); 27 | } 28 | }, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/example-13-issue-80.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/issue-80.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => 10 | createMilestones( 11 | 'Issue #80 - Spacing Fix and Category Styling', 12 | 'Year 1991 has 10 items in Windows timeline. The spacing between timelines is now correct (not affected by item count). Categories (Windows/Mac) have individual styling with categoryStyle.', 13 | args 14 | ); 15 | 16 | export const Issue80 = Template.bind({}); 17 | Issue80.args = { 18 | optimize: true, 19 | aggregateBy: 'year', 20 | parseTime: '%Y', 21 | mapping: { 22 | category: 'system', 23 | entries: 'versions', 24 | timestamp: 'year', 25 | text: 'title', 26 | titleStyle: 'titleStyle', 27 | categoryStyle: 'categoryStyle', 28 | }, 29 | data, 30 | }; 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | schedule: 5 | # * is a special character in YAML so you have to quote this string 6 | - cron: '20,50 * * * *' 7 | jobs: 8 | ui-test: 9 | environment: test-ui 10 | runs-on: ubuntu-latest 11 | env: 12 | APM_SERVER: "${{ secrets.APM_SERVER }}" 13 | PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true' 14 | CI: 'true' 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22' 22 | 23 | - name: Setup Xvfb for browser tests 24 | run: | 25 | export DISPLAY=:99 26 | chromedriver --url-base=/wd/hub & 27 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & 28 | 29 | - name: Install Dependencies 30 | run: yarn install --frozen-lockfile 31 | 32 | - name: Run Tests 33 | run: | 34 | export DISPLAY=:99 35 | yarn test 36 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | staticDirs: ['../src/stories/assets'], 4 | 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-docs', 8 | '@storybook/addon-essentials', 9 | '@storybook/addon-mdx-gfm', 10 | '@storybook/addon-webpack5-compiler-babel', 11 | '@chromatic-com/storybook' 12 | ], 13 | 14 | framework: { 15 | name: '@storybook/html-webpack5', 16 | options: {}, 17 | }, 18 | 19 | webpackFinal: async (config) => { 20 | config.module.rules.push({ 21 | test: /\.less$/, 22 | use: [ 23 | { loader: 'style-loader' }, 24 | { loader: 'css-loader', options: { modules: false } }, 25 | { 26 | loader: 'less-loader', 27 | options: { lessOptions: { javascriptEnabled: true } }, 28 | }, 29 | ], 30 | }); 31 | config.optimization.minimize = false; 32 | return config; 33 | }, 34 | 35 | docs: {}, 36 | }; 37 | -------------------------------------------------------------------------------- /src/stories/example-01-milestones-events.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/milestones-events.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes: Object.assign(argTypes, { 7 | onEventClick: { 8 | action: 'clicked', 9 | }, 10 | onEventMouseOver: { 11 | action: 'mouseover', 12 | }, 13 | onEventMouseLeave: { 14 | action: 'mouseleave', 15 | }, 16 | }), 17 | }; 18 | 19 | const Template = (args) => 20 | createMilestones( 21 | 'Version Milestones with Event API', 22 | `The chart is responsive, try resizing the browser window. Use the storybook options to trigger examples of the chart's features. The chart implements click, mouseover, and mouseleave. Hover or click on labels to call the corresponding action.`, 23 | args 24 | ); 25 | 26 | export const EventsAPI = Template.bind({}); 27 | EventsAPI.args = { 28 | optimize: true, 29 | aggregateBy: 'day', 30 | mapping: { 31 | timestamp: 'timestamp', 32 | text: 'detail', 33 | }, 34 | data, 35 | }; 36 | -------------------------------------------------------------------------------- /src/stories/example-11-ordinal-scale.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | 3 | export default { 4 | title: 'd3-milestones', 5 | argTypes, 6 | }; 7 | 8 | // Sample data for an ordinal scale timeline 9 | const ordinalData = [ 10 | { 11 | step: 'Step 1', 12 | detail: 'Planning phase', 13 | }, 14 | { 15 | step: 'Step 2', 16 | detail: 'Research phase', 17 | }, 18 | { 19 | step: 'Step 3', 20 | detail: 'Development phase', 21 | }, 22 | { 23 | step: 'Step 4', 24 | detail: 'Testing phase', 25 | }, 26 | { 27 | step: 'Step 5', 28 | detail: 'Deployment phase', 29 | }, 30 | ]; 31 | 32 | const Template = (args) => 33 | createMilestones( 34 | 'Ordinal Scale', 35 | 'This example demonstrates using an ordinal scale instead of time scale', 36 | args 37 | ); 38 | 39 | export const OrdinalScale = Template.bind({}); 40 | OrdinalScale.args = { 41 | scaleType: 'ordinal', 42 | optimize: true, 43 | mapping: { 44 | value: 'step', 45 | text: 'detail', 46 | }, 47 | data: ordinalData, 48 | }; 49 | -------------------------------------------------------------------------------- /src/stories/assets/styles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text": "Default Title", 4 | "year": 1980 5 | }, 6 | { 7 | "text": "Green Title", 8 | "year": 1990, 9 | "titleStyle": { "color": "#2BFD00"} 10 | }, 11 | { 12 | "text": "Bold text", 13 | "year": 1990, 14 | "textStyle": { "font-weight": "bold"} 15 | }, 16 | { 17 | "text": "Red Title", 18 | "year": 2000, 19 | "titleStyle": { "color": "#E12800"} 20 | }, 21 | { 22 | "text": "Blue Title", 23 | "year": 2000, 24 | "titleStyle": { "color": "#5BC6FF"} 25 | }, 26 | { 27 | "text": "Red Text, Blue Title", 28 | "year": 2010, 29 | "textStyle": { "color": "#E12800"}, 30 | "titleStyle": { "color": "#5BC6FF"} 31 | }, 32 | { 33 | "text": "Italic text", 34 | "year": 2010, 35 | "textStyle": { "font-style": "italic"} 36 | }, 37 | { 38 | "text": "Large font-size", 39 | "year": 2020, 40 | "textStyle": { "font-size": "16px"} 41 | }, 42 | { 43 | "text": "Small font-size", 44 | "year": 2020, 45 | "textStyle": { "font-size": "8px"} 46 | } 47 | ] 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/_time_parse.js: -------------------------------------------------------------------------------- 1 | import { timeParse as d3TimeParse } from 'd3-time-format'; 2 | 3 | import { aggregateFormats } from './_aggregate_formats'; 4 | 5 | export function timeParse(f) { 6 | if (f === '%Y-Q%Q') { 7 | const quarterParser = d3TimeParse(aggregateFormats.month); 8 | return (d) => { 9 | if (d.search('-Q') === -1) { 10 | const quarter = Math.ceil(parseInt(d.split('-')[1]) / 3); 11 | const quarterFirstMonthAsString = quarter * 3 - 2 + ''; 12 | const quarterFirstMonthLeadingZero = 13 | quarterFirstMonthAsString.length < 2 14 | ? '0' + quarterFirstMonthAsString 15 | : quarterFirstMonthAsString; 16 | return quarterParser( 17 | d.split('-')[0] + '-' + quarterFirstMonthLeadingZero 18 | ); 19 | } else { 20 | const monthAsString = parseInt(d.split('-')[1][1]) * 3 + ''; 21 | const monthLeadingZero = 22 | monthAsString.length < 2 ? '0' + monthAsString : monthAsString; 23 | return quarterParser(d.split('-')[0] + '-' + monthLeadingZero); 24 | } 25 | }; 26 | } 27 | return d3TimeParse(f); 28 | } 29 | -------------------------------------------------------------------------------- /src/stories/example-05-lotr.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | import data from './assets/lotr.json'; 3 | 4 | export default { 5 | title: 'd3-milestones', 6 | argTypes, 7 | }; 8 | 9 | const Template = (args) => { 10 | const gandalfData = args.data.filter((d) => d.character === 'Gandalf'); 11 | const frodoData = args.data.filter((d) => d.character === 'Frodo'); 12 | 13 | const gandalf = createMilestones( 14 | 'Lord of the Rings Categorical Top/Bottom Timeline', 15 | undefined, 16 | Object.assign(args, { 17 | distribution: 'top', 18 | data: gandalfData, 19 | }), 20 | 'timeline-gandalf', 21 | 'height: 200px !important' 22 | ); 23 | const frodo = createMilestones( 24 | undefined, 25 | undefined, 26 | Object.assign(args, { 27 | distribution: 'bottom', 28 | data: frodoData, 29 | }), 30 | 'timeline-frodo', 31 | 'height: 200px !important; margin-top: -200px !important' 32 | ); 33 | return gandalf + frodo; 34 | }; 35 | 36 | export const LordOfTheRings = Template.bind({}); 37 | LordOfTheRings.args = { 38 | optimize: true, 39 | aggregateBy: 'day', 40 | parseTime: '%d.%m.%Y', 41 | data, 42 | }; 43 | -------------------------------------------------------------------------------- /src/_get_available_width.test.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | 3 | import * as scale from 'd3-scale'; 4 | import { extent } from 'd3-array'; 5 | 6 | import { aggregateFormats } from './_aggregate_formats'; 7 | import { getAvailableWidth } from './_get_available_width'; 8 | import { timeParse } from './_time_parse'; 9 | 10 | describe('getAvailableWidth', () => { 11 | it('should get the available width', () => { 12 | const mockElement = { 13 | style: { 14 | width: '10px', 15 | }, 16 | }; 17 | 18 | const mapping = {}; 19 | 20 | const nestedNode = { timelineIndex: 0 }; 21 | 22 | const currentItem = { key: '1990' }; 23 | const nextItem = { key: '2000' }; 24 | 25 | const nestedData = [[nextItem]]; 26 | 27 | const textMerge = { 28 | _groups: [currentItem, nextItem], 29 | }; 30 | 31 | let aggregateFormatParse = timeParse(aggregateFormats.year); 32 | 33 | const width = 200; 34 | 35 | const domain = extent(['1980', '1990', '2020'], (d) => 36 | aggregateFormatParse(d) 37 | ); 38 | 39 | const x = scale.scaleTime().rangeRound([0, width]).domain(domain); 40 | 41 | const availableWidth = getAvailableWidth( 42 | aggregateFormatParse, 43 | mockElement, 44 | 10, 45 | mapping, 46 | nestedData, 47 | nestedNode, 48 | 1, 49 | 100, 50 | 25, 51 | 'width', 52 | 'offsetHeight', 53 | 'horizontal', 54 | textMerge, 55 | width, 56 | x 57 | ); 58 | 59 | expect(availableWidth).toBe(169); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/milestones.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape'; 2 | import milestones from '../src/main'; 3 | import * as d3 from 'd3-selection'; 4 | import { startTransaction } from './apm'; 5 | import { delay } from './delay'; 6 | 7 | const TEST_NAME = 'should render a minimal milestones chart'; 8 | 9 | tape(TEST_NAME, (t) => { 10 | document.body.insertAdjacentHTML( 11 | 'afterbegin', 12 | '
' 13 | ); 14 | 15 | const data = [ 16 | { timestamp: '2012-09-09T00:00', detail: 'v1.0.0' }, 17 | { 18 | timestamp: '2012-09-10T00:00', 19 | detail: 'v1.0.1', 20 | url: 'http://example.com', 21 | }, 22 | { timestamp: '2012-09-12T00:00', detail: 'v1.1.0' }, 23 | ]; 24 | 25 | const { endTransaction } = startTransaction(TEST_NAME); 26 | 27 | const timeline = milestones('#wrapper_milestones').mapping({ 28 | timestamp: 'timestamp', 29 | text: 'detail', 30 | }); 31 | 32 | timeline 33 | .parseTime('%Y-%m-%dT%H:%M') 34 | .aggregateBy('second') 35 | .optimize(true) 36 | .render(data); 37 | 38 | endTransaction(); 39 | 40 | t.plan(4); 41 | 42 | return delay(1000).then(() => { 43 | t.false( 44 | d3.select('#wrapper_milestones .milestones').empty(), 45 | 'should render .milestones element' 46 | ); 47 | t.false( 48 | d3 49 | .select('#wrapper_milestones .milestones .milestones__horizontal_line') 50 | .empty(), 51 | 'should render .milestones__horizontal_line element' 52 | ); 53 | t.equal( 54 | d3.selectAll('#wrapper_milestones .milestones .milestones__group').size(), 55 | 3, 56 | 'should render 3 .milestones__group elements' 57 | ); 58 | t.equal( 59 | d3 60 | .selectAll('#wrapper_milestones .milestones .milestones__group a') 61 | .size(), 62 | 1, 63 | 'should render 1 link' 64 | ); 65 | 66 | t.end(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /karma.config.js: -------------------------------------------------------------------------------- 1 | const babel = require('@rollup/plugin-babel'); 2 | const tapSpec = require('tap-spec'); 3 | const eslint = require('@rollup/plugin-eslint'); 4 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 5 | 6 | const { getBranch } = require('./test/get_git_branch'); 7 | 8 | const APM_GIT_BRANCH = getBranch(); 9 | const APM_SERVER = 10 | process.env.APM_SERVER !== undefined 11 | ? `"${process.env.APM_SERVER}"` 12 | : undefined; 13 | 14 | module.exports = (config) => { 15 | const configuration = { 16 | autoWatch: false, 17 | browsers: ['Firefox'], 18 | browserConsoleLogOptions: { 19 | level: 'error', 20 | format: '%b %T: %m', 21 | terminal: false, 22 | }, 23 | colors: true, 24 | files: [ 25 | 'node_modules/@elastic/apm-rum/dist/bundles/elastic-apm-rum.umd.min.js', 26 | 'build/d3-milestones.css', 27 | 'build/tape.js', 28 | { pattern: 'test/*-test.js', watched: false }, 29 | ], 30 | frameworks: ['tap'], 31 | logLevel: config.LOG_ERROR, 32 | plugins: [ 33 | 'karma-rollup-preprocessor', 34 | 'karma-tap', 35 | 'karma-tap-pretty-reporter', 36 | 'karma-firefox-launcher', 37 | ], 38 | preprocessors: { 39 | 'test/*-test.js': ['rollup'], 40 | }, 41 | reporters: ['tap-pretty'], 42 | rollupPreprocessor: { 43 | external: ['tape'], 44 | output: { 45 | intro: `const APM_GIT_BRANCH = "${APM_GIT_BRANCH}";\nconst APM_SERVER = ${APM_SERVER};\n`, 46 | format: 'iife', 47 | globals: { 48 | tape: 'tape', 49 | }, 50 | sourcemap: 'inline', 51 | }, 52 | plugins: [ 53 | eslint({ 54 | exclude: ['src/styles/**'], 55 | }), 56 | nodeResolve(), 57 | babel({ babelHelpers: 'bundled' }), 58 | ], 59 | }, 60 | singleRun: true, 61 | tapReporter: { 62 | prettify: tapSpec, 63 | }, 64 | }; 65 | 66 | config.set(configuration); 67 | }; 68 | -------------------------------------------------------------------------------- /src/stories/assets/ultima-series.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Akalabeth: World of Doom", 4 | "cover": "https://www.mobygames.com/images/covers/l/51963-akalabeth-world-of-doom-apple-ii-media.jpg", 5 | "year": 1980 6 | }, 7 | { 8 | "title": "Ultima", 9 | "cover": "https://ultimacodex.com/wp-content/uploads/2012/09/Ultima1.jpg", 10 | "year": 1981 11 | }, 12 | { 13 | "title": "Ultima II: The Revenge of the Enchantress...", 14 | "cover": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/71/On-Line-Systems-Ultima2.jpg/500px-On-Line-Systems-Ultima2.jpg", 15 | "year": 1982 16 | }, 17 | { 18 | "title": "Exodus: Ultima III", 19 | "cover": "https://ultimacodex.com/wp-content/uploads/2012/09/Exodus.jpg", 20 | "year": 1983 21 | }, 22 | { 23 | "title": "Ultima IV: Quest of the Avatar", 24 | "cover": "https://ultimacodex.com/wp-content/uploads/2012/09/ultimaIV.jpg", 25 | "year": 1985 26 | }, 27 | { 28 | "title": "Ultima V: Warriors of Destiny", 29 | "cover": "https://www.mobygames.com/images/covers/l/286483-ultima-v-warriors-of-destiny-windows-front-cover.jpg", 30 | "year": 1988 31 | }, 32 | { 33 | "title": "Ultima VI: The False Prophet", 34 | "cover": "https://upload.wikimedia.org/wikipedia/en/4/4a/Ultima_6_cover.png", 35 | "year": 1990 36 | }, 37 | { 38 | "title": "Ultima VII: The Black Gate", 39 | "cover": "https://ultimacodex.com/wp-content/uploads/2012/09/Ultima7.jpg", 40 | "year": 1992 41 | }, 42 | { 43 | "title": "Ultima VII: Part Two - Serpent Isle", 44 | "cover": "https://www.mobygames.com/images/covers/l/165651-ultima-vii-part-two-serpent-isle-dos-front-cover.jpg", 45 | "year": 1993 46 | }, 47 | { 48 | "title": "Pagan: Ultima VIII", 49 | "cover": "https://www.mobygames.com/images/covers/l/2748-pagan-ultima-viii-dos-front-cover.jpg", 50 | "year": 1994 51 | }, 52 | { 53 | "title": "Ultima IX: Ascension", 54 | "cover": "https://ultimacodex.com/wp-content/uploads/2012/09/Ultima9.jpg", 55 | "year": 1999 56 | } 57 | ] 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm dependencies 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "09:00" 10 | timezone: "UTC" 11 | open-pull-requests-limit: 10 12 | reviewers: 13 | - "walterra" 14 | assignees: 15 | - "walterra" 16 | commit-message: 17 | prefix: "deps" 18 | prefix-development: "deps" 19 | include: "scope" 20 | labels: 21 | - "dependencies" 22 | cooldown: 23 | default-days: 14 # 14 days for supply chain attack mitigation 24 | semver-major-days: 21 # Longer for major versions (higher risk) 25 | semver-patch-days: 7 # Shorter for security patches 26 | groups: 27 | d3: 28 | patterns: 29 | - "d3-*" 30 | babel: 31 | patterns: 32 | - "@babel/*" 33 | - "babel-*" 34 | rollup: 35 | patterns: 36 | - "rollup" 37 | - "@rollup/*" 38 | storybook: 39 | patterns: 40 | - "storybook" 41 | - "@storybook/*" 42 | - "@chromatic-com/*" 43 | testing: 44 | patterns: 45 | - "jest" 46 | - "jest-*" 47 | - "karma" 48 | - "karma-*" 49 | - "tape" 50 | - "tape-*" 51 | - "tap-*" 52 | linting: 53 | patterns: 54 | - "eslint" 55 | - "eslint-*" 56 | - "prettier" 57 | webpack: 58 | patterns: 59 | - "webpack" 60 | - "*-loader" 61 | 62 | # Enable version updates for GitHub Actions 63 | - package-ecosystem: "github-actions" 64 | directory: "/" 65 | schedule: 66 | interval: "weekly" 67 | day: "monday" 68 | time: "09:00" 69 | timezone: "UTC" 70 | commit-message: 71 | prefix: "ci" 72 | include: "scope" 73 | labels: 74 | - "github-actions" 75 | - "ci" 76 | cooldown: 77 | default-days: 14 # 14 days for GitHub Actions supply chain protection 78 | -------------------------------------------------------------------------------- /test/event.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape'; 2 | import milestones from '../src/main'; 3 | import * as d3 from 'd3-selection'; 4 | import { startTransaction } from './apm'; 5 | import { delay } from './delay'; 6 | 7 | const TEST_NAME = 8 | 'should render a minimal milestones chart with attached events'; 9 | 10 | tape(TEST_NAME, (t) => { 11 | document.body.insertAdjacentHTML( 12 | 'afterbegin', 13 | '
' 14 | ); 15 | 16 | const data = [ 17 | { timestamp: '2012-09-09T00:00', detail: 'v1.0.0' }, 18 | { timestamp: '2012-09-10T00:00', detail: 'v1.0.1' }, 19 | { timestamp: '2012-09-12T00:00', detail: 'v1.1.0' }, 20 | ]; 21 | 22 | const { endTransaction } = startTransaction(TEST_NAME); 23 | 24 | const timeline = milestones('#wrapper_event') 25 | .onEventClick((d) => { 26 | t.equal(d.text, 'v1.0.0', 'click event text should match label text'); 27 | }) 28 | .onEventMouseOver((d) => { 29 | t.equal(d.text, 'v1.0.0', 'mouseover event text should match label text'); 30 | }) 31 | .onEventMouseLeave((d) => { 32 | t.equal(d.text, 'v1.0.0', 'mouseover event text should match label text'); 33 | }) 34 | .mapping({ 35 | timestamp: 'timestamp', 36 | text: 'detail', 37 | }); 38 | 39 | timeline 40 | .parseTime('%Y-%m-%dT%H:%M') 41 | .aggregateBy('second') 42 | .optimize(true) 43 | .render(data); 44 | 45 | endTransaction(); 46 | 47 | t.plan(3); 48 | 49 | return delay(1000).then(function () { 50 | d3.select('#wrapper_event .milestones-text-label').each(function (d, i) { 51 | var onClickFunc = d3.select(this).on('click'); 52 | onClickFunc.apply(this, [d, i]); 53 | }); 54 | 55 | d3.select('#wrapper_event .milestones-text-label').each(function (d, i) { 56 | var onClickFunc = d3.select(this).on('mouseover'); 57 | onClickFunc.apply(this, [d, i]); 58 | }); 59 | 60 | d3.select('#wrapper_event .milestones-text-label').each(function (d, i) { 61 | var onClickFunc = d3.select(this).on('mouseleave'); 62 | onClickFunc.apply(this, [d, i]); 63 | }); 64 | 65 | t.end(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/stories/assets/milestones-events.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "timestamp":"2012-09-09T00:00", 4 | "detail":"v1.0.0" 5 | }, 6 | { 7 | "timestamp":"2012-09-10T00:00", 8 | "detail":"v1.0.1" 9 | }, 10 | { 11 | "timestamp":"2012-09-12T00:00", 12 | "detail":"v1.1.0" 13 | }, 14 | { 15 | "timestamp":"2012-09-15T00:00", 16 | "detail":"v1.1.1" 17 | }, 18 | { 19 | "timestamp":"2012-09-26T00:00", 20 | "detail":"v1.2.0" 21 | }, 22 | { 23 | "timestamp":"2012-10-10T00:00", 24 | "detail":"v2.0.0" 25 | }, 26 | { 27 | "timestamp":"2012-10-31T00:00", 28 | "detail":"v2.1.0" 29 | }, 30 | { 31 | "timestamp":"2012-11-07T00:00", 32 | "detail":"v2.2.0" 33 | }, 34 | { 35 | "timestamp":"2012-11-25T00:00", 36 | "detail":"v2.2.1" 37 | }, 38 | { 39 | "timestamp":"2012-12-01T00:00", 40 | "detail":"v2.3.0" 41 | }, 42 | { 43 | "timestamp":"2012-12-05T00:00", 44 | "detail":"v2.3.1" 45 | }, 46 | { 47 | "timestamp":"2013-01-03T00:00", 48 | "detail":"v3.0.0" 49 | }, 50 | { 51 | "timestamp":"2013-01-09T00:00", 52 | "detail":"v3.0.1" 53 | }, 54 | { 55 | "timestamp":"2013-01-16T00:00", 56 | "detail":"v3.1.0" 57 | }, 58 | { 59 | "timestamp":"2013-01-16T00:00", 60 | "detail":"v4.0.0" 61 | }, 62 | { 63 | "timestamp":"2013-01-17T00:00", 64 | "detail":"v4.0.1" 65 | }, 66 | { 67 | "timestamp":"2013-02-08T00:00", 68 | "detail":"v4.1.0" 69 | }, 70 | { 71 | "timestamp":"2013-03-06T00:00", 72 | "detail":"v4.1.1" 73 | }, 74 | { 75 | "timestamp":"2013-03-06T00:00", 76 | "detail":"v4.2.0" 77 | }, 78 | { 79 | "timestamp":"2013-03-09T00:00", 80 | "detail":"v4.3.0" 81 | }, 82 | { 83 | "timestamp":"2013-03-27T00:00", 84 | "detail":"v4.4.0" 85 | }, 86 | { 87 | "timestamp":"2013-04-13T00:00", 88 | "detail":"v4.5.0" 89 | }, 90 | { 91 | "timestamp":"2013-05-04T00:00", 92 | "detail":"v5.0.0" 93 | }, 94 | { 95 | "timestamp":"2013-07-06T00:00", 96 | "detail":"v5.0.1" 97 | }, 98 | { 99 | "timestamp":"2013-08-01T00:00", 100 | "detail":"v5.1.0" 101 | } 102 | ] 103 | -------------------------------------------------------------------------------- /src/stories/example-12-ordinal-scale-categories.stories.js: -------------------------------------------------------------------------------- 1 | import { argTypes, createMilestones } from './milestones'; 2 | 3 | export default { 4 | title: 'd3-milestones', 5 | argTypes, 6 | }; 7 | 8 | // Sample data for an ordinal scale timeline with categories 9 | const categoriesData = [ 10 | { 11 | category: 'Frontend', 12 | steps: [ 13 | { 14 | step: 'Requirements', 15 | detail: 'Gather interface requirements', 16 | }, 17 | { 18 | step: 'Wireframes', 19 | detail: 'Create wireframes and mockups', 20 | }, 21 | { 22 | step: 'Implementation', 23 | detail: 'Develop frontend components', 24 | }, 25 | { 26 | step: 'Testing', 27 | detail: 'Test frontend components', 28 | }, 29 | ], 30 | }, 31 | { 32 | category: 'Backend', 33 | steps: [ 34 | { 35 | step: 'Architecture', 36 | detail: 'Design system architecture', 37 | }, 38 | { 39 | step: 'Database', 40 | detail: 'Create database schema', 41 | }, 42 | { 43 | step: 'API', 44 | detail: 'Implement API endpoints', 45 | }, 46 | { 47 | step: 'Integration', 48 | detail: 'Connect frontend and backend', 49 | }, 50 | ], 51 | }, 52 | { 53 | category: 'DevOps', 54 | steps: [ 55 | { 56 | step: 'Environment', 57 | detail: 'Set up development environment', 58 | }, 59 | { 60 | step: 'CI/CD', 61 | detail: 'Configure CI/CD pipeline', 62 | }, 63 | { 64 | step: 'Deployment', 65 | detail: 'Prepare deployment strategy', 66 | }, 67 | { 68 | step: 'Monitoring', 69 | detail: 'Set up monitoring tools', 70 | }, 71 | ], 72 | }, 73 | ]; 74 | 75 | const Template = (args) => 76 | createMilestones( 77 | 'Ordinal Scale with Categories', 78 | 'This example demonstrates using an ordinal scale with multiple categories', 79 | args 80 | ); 81 | 82 | export const OrdinalScaleCategories = Template.bind({}); 83 | OrdinalScaleCategories.args = { 84 | scaleType: 'ordinal', 85 | optimize: true, 86 | mapping: { 87 | category: 'category', 88 | entries: 'steps', 89 | value: 'step', 90 | text: 'detail', 91 | }, 92 | data: categoriesData, 93 | }; 94 | -------------------------------------------------------------------------------- /src/_transform.js: -------------------------------------------------------------------------------- 1 | import { ascending } from 'd3-array'; 2 | import { nest } from 'd3-collection'; 3 | 4 | export function transform( 5 | aggregateFormat, 6 | data, 7 | mapping, 8 | parseTime, 9 | scaleType = 'time' 10 | ) { 11 | // Choose grouping function based on scale type 12 | const groupBy = function (d) { 13 | if (scaleType === 'ordinal') { 14 | // For ordinal scales, use the value field directly 15 | return d[mapping.value]; 16 | } else { 17 | // For time scales, use the timestamp with formatting 18 | return aggregateFormat(parseTime(d[mapping.timestamp])); 19 | } 20 | }; 21 | 22 | // test for different data structures 23 | if ( 24 | typeof mapping.category !== 'undefined' && 25 | typeof mapping.entries !== 'undefined' 26 | ) { 27 | data = data.map((timeline, timelineIndex) => { 28 | return { 29 | category: timeline[mapping.category], 30 | entries: getNestedEntries(timeline[mapping.entries], timelineIndex), 31 | originalData: timeline, // Preserve original data for accessing categoryStyle etc. 32 | }; 33 | }); 34 | return data; 35 | } else if (typeof data !== 'undefined' && !Array.isArray(data[0])) { 36 | data = [data]; 37 | } 38 | 39 | function getNestedEntries(t, tI) { 40 | // For ordinal scales, we need to preserve the original order 41 | // For time scales, we want to sort by time (ascending) 42 | const nested = 43 | scaleType === 'ordinal' 44 | ? nest().key(groupBy).entries(t) // Don't sort keys for ordinal scale 45 | : nest().key(groupBy).sortKeys(ascending).entries(t); 46 | 47 | // Save original data order for ordinal scales 48 | if (scaleType === 'ordinal') { 49 | // Create a map of original positions 50 | const originalPositions = {}; 51 | t.forEach((item, index) => { 52 | const key = groupBy(item); 53 | if (!originalPositions[key] && originalPositions[key] !== 0) { 54 | originalPositions[key] = index; 55 | } 56 | }); 57 | 58 | // Sort the nested entries by their original position 59 | nested.sort((a, b) => { 60 | return ( 61 | (originalPositions[a.key] || 0) - (originalPositions[b.key] || 0) 62 | ); 63 | }); 64 | } 65 | 66 | return nested.map((d, dI) => { 67 | d.index = dI; 68 | d.timelineIndex = tI; 69 | d.scaleType = scaleType; // Pass the scale type to the data object 70 | return d; 71 | }); 72 | } 73 | 74 | return data.map((t, tI) => getNestedEntries(t, tI)); 75 | } 76 | -------------------------------------------------------------------------------- /src/_get_available_width.js: -------------------------------------------------------------------------------- 1 | import { getAttribute } from './_get_attribute'; 2 | 3 | const labelRightMargin = 6; 4 | 5 | export const getAvailableWidth = ( 6 | aggregateFormatParse, 7 | currentNode, 8 | index, 9 | mapping, 10 | nestedData, 11 | nestedNode, 12 | nextCheck, 13 | nextGroupHeight, 14 | offset, 15 | offsetCheckAttribute, 16 | offsetAttribute, 17 | orientation, 18 | textMerge, 19 | width, 20 | x, 21 | useNext = true, 22 | scaleType = 'time' // Default to time scale if not provided 23 | ) => { 24 | // get the available width until the uber-next group 25 | let nextTestIndex = 26 | orientation === 'horizontal' && useNext 27 | ? index + nextCheck 28 | : index - nextCheck; 29 | 30 | let nextTestItem; 31 | 32 | do { 33 | if (orientation === 'horizontal' && useNext) { 34 | nextTestIndex += nextCheck; 35 | } else { 36 | nextTestIndex -= nextCheck; 37 | } 38 | nextTestItem = textMerge._groups[nextTestIndex]; 39 | if (typeof nextTestItem === 'undefined') { 40 | break; 41 | } 42 | } while (nextGroupHeight >= nextTestItem[0][offsetAttribute]); 43 | 44 | let uberNextItem; 45 | 46 | if (typeof mapping.category === 'undefined') { 47 | uberNextItem = nestedData[nestedNode.timelineIndex][nextTestIndex]; 48 | } else { 49 | uberNextItem = nestedData[nestedNode.timelineIndex].entries[nextTestIndex]; 50 | } 51 | 52 | let availableWidth = getAttribute(currentNode, offsetCheckAttribute); 53 | 54 | if (typeof uberNextItem !== 'undefined') { 55 | const value = 56 | scaleType === 'ordinal' 57 | ? uberNextItem.key 58 | : aggregateFormatParse(uberNextItem.key); 59 | const offsetUberNextItem = x(value); 60 | 61 | if ((orientation === 'horizontal') & useNext) { 62 | availableWidth = offsetUberNextItem - offset - labelRightMargin; 63 | } else if ((orientation === 'horizontal') & !useNext) { 64 | availableWidth = offsetUberNextItem - labelRightMargin; 65 | } else { 66 | availableWidth = offset - offsetUberNextItem - labelRightMargin; 67 | } 68 | } else { 69 | if ((orientation === 'horizontal') & useNext) { 70 | availableWidth = width - offset - labelRightMargin; 71 | } else if ((orientation === 'horizontal') & !useNext) { 72 | availableWidth = offset - labelRightMargin; 73 | } else { 74 | availableWidth = offset - labelRightMargin; 75 | } 76 | } 77 | 78 | if (nextCheck < 0) { 79 | return Math.min(offset, availableWidth); 80 | } else { 81 | return availableWidth; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/stories/assets/issue-80.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "system": "Windows", 4 | "categoryStyle": { "color": "#0078D4", "font-weight": "bold", "font-size": "1.2em" }, 5 | "versions": [ 6 | { 7 | "year": 1985, 8 | "title": "Windows 1.0" 9 | }, 10 | { 11 | "year": 1991, 12 | "title": "Item 1 - Demo", 13 | "titleStyle": { "color": "#0078D4", "font-weight": "bold" } 14 | }, 15 | { 16 | "year": 1991, 17 | "title": "Item 2 - Demo", 18 | "titleStyle": { "color": "#E12800" } 19 | }, 20 | { 21 | "year": 1991, 22 | "title": "Item 3 - Demo" 23 | }, 24 | { 25 | "year": 1991, 26 | "title": "Item 4 - Demo", 27 | "titleStyle": { "font-style": "italic" } 28 | }, 29 | { 30 | "year": 1991, 31 | "title": "Item 5 - Demo" 32 | }, 33 | { 34 | "year": 1991, 35 | "title": "Item 6 - Demo" 36 | }, 37 | { 38 | "year": 1991, 39 | "title": "Item 7 - Demo", 40 | "titleStyle": { "color": "#2BFD00" } 41 | }, 42 | { 43 | "year": 1991, 44 | "title": "Item 8 - Demo" 45 | }, 46 | { 47 | "year": 1991, 48 | "title": "Item 9 - Demo" 49 | }, 50 | { 51 | "year": 1991, 52 | "title": "Item 10 - Demo", 53 | "titleStyle": { "color": "#5BC6FF", "font-size": "1.2em" } 54 | }, 55 | { 56 | "year": 1995, 57 | "title": "Windows 95" 58 | }, 59 | { 60 | "year": 2001, 61 | "title": "Windows XP" 62 | }, 63 | { 64 | "year": 2007, 65 | "title": "Windows Vista" 66 | }, 67 | { 68 | "year": 2012, 69 | "title": "Windows 8" 70 | } 71 | ] 72 | }, 73 | { 74 | "system": "Mac", 75 | "categoryStyle": { "color": "#A2AAAD", "font-style": "italic", "font-size": "1.2em" }, 76 | "versions": [ 77 | { 78 | "year": 1991, 79 | "title": "System 7.0", 80 | "titleStyle": { "color": "#A2AAAD", "font-style": "italic" } 81 | }, 82 | { 83 | "year": 2001, 84 | "title": "Mac OS X 10.0" 85 | }, 86 | { 87 | "year": 2001, 88 | "title": "Mac OS X 10.1" 89 | }, 90 | { 91 | "year": 2007, 92 | "title": "Mac OS X 10.5" 93 | }, 94 | { 95 | "year": 2007, 96 | "title": "iPhone Launch" 97 | } 98 | ] 99 | } 100 | ] 101 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | environment: npm-publish 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | id-token: write 19 | steps: 20 | - name: Generate GitHub App Token 21 | id: app-token 22 | uses: actions/create-github-app-token@v1 23 | with: 24 | app-id: ${{ vars.APP_ID }} 25 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 26 | 27 | - name: Checkout Repo 28 | uses: actions/checkout@v4 29 | with: 30 | token: ${{ steps.app-token.outputs.token }} 31 | fetch-depth: 0 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: "yarn" 38 | 39 | - name: Upgrade npm for OIDC support 40 | run: npm install -g npm@latest 41 | 42 | - name: Setup Xvfb for browser tests 43 | run: | 44 | export DISPLAY=:99 45 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & 46 | 47 | - name: Install Dependencies 48 | run: yarn install --frozen-lockfile 49 | 50 | - name: Run Tests 51 | run: | 52 | export DISPLAY=:99 53 | yarn test 54 | 55 | - name: Get Release Version 56 | id: release-version 57 | run: | 58 | yarn changeset status --output=release.json 2>/dev/null || true 59 | if [ -f release.json ]; then 60 | VERSION=$(jq -r '.releases[0].newVersion // empty' release.json) 61 | rm release.json 62 | if [ -n "$VERSION" ]; then 63 | echo "version=$VERSION" >> $GITHUB_OUTPUT 64 | echo "title=release v$VERSION" >> $GITHUB_OUTPUT 65 | else 66 | echo "title=chore: release" >> $GITHUB_OUTPUT 67 | fi 68 | else 69 | echo "title=chore: release" >> $GITHUB_OUTPUT 70 | fi 71 | 72 | - name: Create Release Pull Request or Publish 73 | id: changesets 74 | uses: changesets/action@v1 75 | with: 76 | title: ${{ steps.release-version.outputs.title }} 77 | publish: yarn release 78 | createGithubReleases: true 79 | version: yarn version-packages 80 | env: 81 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 82 | DISPLAY: :99 83 | 84 | - name: Upload Release Assets 85 | if: steps.changesets.outputs.published == 'true' 86 | env: 87 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 88 | run: | 89 | # Upload zip file to GitHub release 90 | if [ -f build/d3-milestones.zip ]; then 91 | VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version') 92 | gh release upload "v${VERSION}" build/d3-milestones.zip --clobber 93 | fi 94 | -------------------------------------------------------------------------------- /src/stories/assets/covid19.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "date":"2020-11-09", 4 | "title":"\"Trotz vorgezogener Virensaison (SARS-CoV-2 Welle und Grippewelle) wird es zu keiner Überforderung der Versorgung kommen.\", Martin Sprenger" 5 | }, 6 | { 7 | "date":"2020-11-07", 8 | "title":"\"Der (PCR-)Test ist nicht zur Diagnostik zugelassen.\", Tiroler Tageszeitung" 9 | }, 10 | { 11 | "date":"2020-10-12", 12 | "title":"\"Die rinnende Nase, haben wir aus der Falldefinition für diese neue Covid-Erkrankung herausgestrichen.\", Franz Allerberger" 13 | }, 14 | { 15 | "date":"2020-10-06", 16 | "title":"\"Schweden ist einen sehr guten Weg gegangen.\", Petra Apfalter" 17 | }, 18 | { 19 | "date":"2020-09-29", 20 | "title":"\"Wir sollten mehr Normalität wagen\", \"mehr Gelassenheit im Umgang mit dem Coronavirus!\", Günter Weiss" 21 | }, 22 | { 23 | "date":"2020-09-26", 24 | "title":"\"Welle ist ein Angst-Wort.\", \"Generell werden Kinder nicht als Treiber der Epidemie angesehen.\", Daniela Schmid" 25 | }, 26 | { 27 | "date":"2020-09-21", 28 | "title":"\"Die Sorge, dass Kinder die Grosseltern infizieren, das kann man heute viel pragmatischer sehen.\", Franz Allerberger" 29 | }, 30 | { 31 | "date":"2020-09-18", 32 | "title":"\"Wir haben keine zweite Welle, wir haben einen Labor-Tsunami\", Petra Apfalter" 33 | }, 34 | { 35 | "date":"2020-09-15", 36 | "title":"\"Lockdown wäre nicht notwendig gewesen\", Franz Allerberger" 37 | }, 38 | { 39 | "date":"2020-08-28", 40 | "title":"\"Es gibt Licht am Ende des Tunnels. Es ist sehr wahrscheinlich, dass diese Corona-Krise kürzer andauern wird, als viele Experten ursprünglich vorhergesagt haben. Wir werden in absehbarer Zeit zur gewohnten Normalität zurückkehren können.\", Sebastian Kurz" 41 | }, 42 | { 43 | "date":"2020-08-22", 44 | "title":"\"Eines ist sicher, zu einer Überforderung der Krankenversorgung wird es mit hundertprozentiger Sicherheit nicht kommen.\", Martin Sprenger" 45 | }, 46 | { 47 | "date":"2020-08-21", 48 | "title":"\"Bei Covid-19 können wir aber sagen, dass Kinder hier keine Bedeutung haben.\", AGES FAQ Coronavirus" 49 | }, 50 | { 51 | "date":"2020-08-01", 52 | "title":"\"Das Virus hat keine Flügel\", Franz Allerberger" 53 | }, 54 | { 55 | "date":"2020-07-27", 56 | "title":"\"50 Prozent Durchseuchungsrate in Balkan-Region\", Franz Allerberger" 57 | }, 58 | { 59 | "date":"2020-07-21", 60 | "title":"\"Kein Beleg für Maskenpflicht\", Tiroler Tageszeitung" 61 | }, 62 | { 63 | "date":"2020-07-21", 64 | "title":"\"Die Gefahr, dass das Virus überhandnimmt und es zu einer zweiten Welle kommt, hält Sönnichsen für äußerst gering.\", addendum.org" 65 | }, 66 | { 67 | "date":"2020-06-13", 68 | "title":"\"Nachdem wir die gesundheitlichen Folgen der Krise überstanden haben, müssen wir jetzt angesichts der Weltwirtschaftskrise die Konjunktur in Österreich wieder ankurbeln.\", Sebastian Kurz" 69 | }, 70 | { 71 | "date":"2020-03-30", 72 | "title":"\"Leider gibt es nur wenige mutige Länder, etwa Schweden, die einen anderen Weg wagen.\", \"diese lächerlichen Masken\", Claudia Wild" 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /src/stories/milestones.js: -------------------------------------------------------------------------------- 1 | import '../styles/styles.less'; 2 | import './example-styles.less'; 3 | 4 | import milestones from '../main'; 5 | 6 | // used to increment the wrapping DIV's id. 7 | let iteration = 0; 8 | 9 | export const argTypes = { 10 | optimize: { 11 | control: { type: 'boolean' }, 12 | }, 13 | autoResize: { 14 | control: { type: 'boolean' }, 15 | }, 16 | distribution: { 17 | options: ['top-bottom', 'top', 'bottom'], 18 | control: { type: 'radio' }, 19 | }, 20 | orientation: { 21 | options: ['horizontal', 'vertical'], 22 | control: { type: 'radio' }, 23 | }, 24 | aggregateBy: { 25 | options: [ 26 | 'second', 27 | 'minute', 28 | 'hour', 29 | 'day', 30 | 'week', 31 | 'month', 32 | 'quarter', 33 | 'year', 34 | ], 35 | control: { type: 'select' }, 36 | }, 37 | parseTime: { 38 | control: { type: 'text' }, 39 | }, 40 | mapping: { 41 | control: { type: 'object' }, 42 | }, 43 | data: { 44 | control: { type: 'object' }, 45 | }, 46 | urlTarget: { 47 | options: ['_blank', '_self', '_parent', '_top'], 48 | control: { type: 'radio' }, 49 | }, 50 | scaleType: { 51 | options: ['time', 'ordinal'], 52 | control: { type: 'radio' }, 53 | }, 54 | }; 55 | 56 | export const createMilestones = ( 57 | title, 58 | description, 59 | { 60 | aggregateBy, 61 | data, 62 | distribution, 63 | mapping, 64 | optimize, 65 | onEventClick, 66 | onEventMouseOver, 67 | onEventMouseLeave, 68 | orientation, 69 | parseTime, 70 | autoResize, 71 | urlTarget, 72 | scaleType, 73 | }, 74 | DIV_ID = 'timeline', 75 | style = '' 76 | ) => { 77 | iteration++; 78 | 79 | const divId = `${DIV_ID}-${iteration}`; 80 | 81 | function render() { 82 | const m = milestones(`#${divId}`); 83 | 84 | mapping && m.mapping(mapping); 85 | aggregateBy && m.aggregateBy(aggregateBy); 86 | distribution && m.distribution(distribution); 87 | optimize && m.optimize(optimize); 88 | onEventClick && m.onEventClick(onEventClick); 89 | onEventMouseOver && m.onEventMouseOver(onEventMouseOver); 90 | onEventMouseLeave && m.onEventMouseLeave(onEventMouseLeave); 91 | orientation && m.orientation(orientation); 92 | parseTime && m.parseTime(parseTime); 93 | autoResize && m.autoResize(autoResize); 94 | urlTarget && m.urlTarget(urlTarget); 95 | scaleType && m.scaleType(scaleType); 96 | 97 | m.render(data); 98 | } 99 | 100 | // Wait until the wrapping DIV exists, only then render. 101 | function checkElement() { 102 | const wrapper = document.getElementById(divId); 103 | if (!wrapper) { 104 | window.setTimeout(checkElement, 100); 105 | } else { 106 | render(); 107 | } 108 | } 109 | 110 | checkElement(); 111 | 112 | const timeline = `
`; 113 | 114 | if (!title && !description) { 115 | return timeline; 116 | } 117 | 118 | return ` 119 |
120 | ${title ? `

${title}

` : ''} 121 | ${description ? `

${description}

` : ''} 122 | ${timeline} 123 |
124 | `; 125 | }; 126 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to d3-milestones 2 | 3 | Contributions are welcome! This document provides guidelines for contributing to the project. 4 | This is important: Before contributing a PR, check in with the maintainer. Comment on an existing issue if you want to pick it up or create a new one with a description of what you'd like to work on. Without this, PRs might get rejected if they don't align with the scope of the project. 5 | 6 | ## Getting Started 7 | 8 | 1. Fork the repository 9 | 2. Clone your fork 10 | 3. Install dependencies: `yarn install` 11 | 4. Create a branch for your feature/fix 12 | 13 | ## Development 14 | 15 | - Build: `yarn build` 16 | - Start dev server: `yarn start` or `yarn watch` 17 | - Run tests: `yarn test` 18 | - Run linter: `yarn lint` (use `yarn lint --fix` to auto-fix) 19 | - Storybook: `yarn storybook` 20 | 21 | ## Making Changes 22 | 23 | ### Code Style 24 | 25 | - Use ES6 modules with named exports 26 | - Follow ESLint and Prettier rules defined in .eslintrc.json 27 | - Write Jest tests for new functionality 28 | - Use kebab-case for filenames (with leading underscore for internal modules) 29 | - Follow D3.js conventions and API patterns 30 | - Keep code backward compatible when possible 31 | 32 | ### Adding a Changeset 33 | 34 | When you make a change that should be released, run: 35 | 36 | ```bash 37 | yarn changeset 38 | ``` 39 | 40 | This will prompt you to: 41 | 42 | 1. Select the type of change: 43 | - **patch**: Bug fixes and minor improvements 44 | - **minor**: New features (backward compatible) 45 | - **major**: Breaking changes 46 | 2. Write a clear summary of the change 47 | 48 | The changeset file will be committed with your PR. 49 | 50 | ### Documentation 51 | 52 | - Update README.md for user-facing changes 53 | - Add JSDoc comments for new public APIs 54 | - Keep CLAUDE.md updated for architectural changes 55 | 56 | ## Pull Requests 57 | 58 | - Create a PR with a clear description 59 | - Ensure all tests pass (`yarn test`) 60 | - Ensure linting passes (`yarn lint`) 61 | - Include a changeset if the change should be released 62 | - Reference any related issues 63 | 64 | ## Release Process 65 | 66 | Releases are automated via GitHub Actions. See [docs/RELEASE_PROCESS.md](docs/RELEASE_PROCESS.md) for complete details. 67 | 68 | Quick summary: 69 | 1. PRs with changesets merged to `main` trigger Release PR creation 70 | 2. Release PR is reviewed and merged by maintainers 71 | 3. Package is automatically published to npm with provenance attestations 72 | 73 | ## Reporting Issues 74 | 75 | - Check existing issues first 76 | - Include browser/Node.js version 77 | - Provide a minimal reproduction example 78 | - Include expected vs actual behavior 79 | 80 | ## Code Review 81 | 82 | All submissions require review. Maintainers will provide feedback and may request changes. 83 | 84 | ## Documentation Style 85 | 86 | For JSDoc comments, git messages, and documentation: 87 | 88 | - Use present tense verbs (fetches, calculates, returns) 89 | - Avoid filler words (very, really, just, simply, basically, actually, literally, comprehensive) 90 | - Avoid hedging (probably, maybe, might, could, should) 91 | - Avoid obvious phrases (please note, it's important to, keep in mind) 92 | - Keep sentences under 20 words 93 | - Be direct and factual 94 | - Use active voice only 95 | 96 | ## License 97 | 98 | By contributing, you agree that your contributions will be licensed under the Apache-2.0 License. 99 | -------------------------------------------------------------------------------- /src/styles/styles.less: -------------------------------------------------------------------------------- 1 | .milestones { 2 | } 3 | 4 | .milestones__category_label { 5 | display: inline-block; 6 | text-align: right; 7 | font-size: 14px; 8 | line-height: 1; 9 | position: relative; 10 | top: calc(4px + 1.5px); /* Line margin-top + half line height */ 11 | transform: translateY(50%); /* Shift down by half the label's height */ 12 | } 13 | 14 | .milestones__horizontal_line { 15 | position: absolute; 16 | background-color: #000; 17 | height: 3px; 18 | margin-top: 4px; 19 | margin-left: 5.5px; 20 | border-radius: 1.5px; 21 | } 22 | 23 | .milestones__vertical_line { 24 | position: absolute; 25 | background-color: #000; 26 | width: 3px; 27 | margin-left: 4px; 28 | margin-bottom: 5.5px; 29 | border-radius: 1.5px; 30 | } 31 | 32 | .milestones__group { 33 | position: absolute; 34 | font-family: sans-serif; 35 | font-size: 10px; 36 | } 37 | 38 | .milestones__group__bullet { 39 | background-color: #fff; 40 | border: 3px solid #333; 41 | border-radius: 50%; 42 | width: 0px; 43 | height: 0px; 44 | padding: 2.5px; 45 | position: relative; 46 | left: 5.5px; /* Center on timeline (margin-left of line) */ 47 | top: 5.5px; /* Center on timeline (margin-top + half line height) */ 48 | transform: translate(-50%, -50%); /* Center the bullet itself */ 49 | } 50 | 51 | .milestones__group__label-horizontal,.milestones__group__label-vertical { 52 | position: absolute; 53 | padding: 0; 54 | color: #666; 55 | 56 | } 57 | 58 | .milestones__group__label-horizontal { 59 | border-left: 1px solid #000; 60 | margin-left: 5.5px; /* Align with bullet center */ 61 | 62 | div { 63 | position: relative; 64 | margin-left: 3px; 65 | display: inline-block; 66 | } 67 | } 68 | 69 | .milestones__group__label-vertical { 70 | padding-left: 10px; 71 | padding-bottom: 0px; 72 | border-bottom: 1px solid #000; 73 | margin-bottom: -5.5px; 74 | margin-left: 10px; 75 | bottom: 100%; 76 | overflow: visible; 77 | .wrapper { 78 | min-width: 100px; 79 | max-width: 300px; 80 | border-left: 1px solid black; 81 | border-bottom: 1px solid white; 82 | margin-bottom: -1px; 83 | padding-left: 5px; 84 | } 85 | } 86 | 87 | .milestones__group__label-above-horizontal { 88 | bottom: 100%; 89 | } 90 | 91 | .milestones__group__label-above-vertical { 92 | padding-left: 0px; 93 | padding-right: 10px; 94 | right: 100%; 95 | text-align: right; 96 | .wrapper { 97 | border-left: 0; 98 | border-right: 1px solid black; 99 | padding-left: 0px; 100 | padding-right: 5px; 101 | } 102 | } 103 | 104 | .milestones__group__label-last { 105 | right: 100%; 106 | border-left: 0; 107 | border-right: 1px solid #000; 108 | margin-left: 0; 109 | margin-right: -6px; 110 | text-align: right; 111 | 112 | div { 113 | margin-left: 0px; 114 | margin-right: 3px; 115 | } 116 | } 117 | 118 | .milestones__group__label__text-vertical { 119 | display: table-cell; 120 | vertical-align: bottom; 121 | } 122 | 123 | .milestones__group__label__text__title { 124 | color: #000; 125 | font-weight: bold; 126 | font-size: 11px; 127 | white-space: nowrap; 128 | } 129 | .milestones__group__label__text__event { 130 | cursor: pointer; 131 | } 132 | .milestones__group__label__text__event--hover { 133 | background: #efefef; 134 | color: #313131; 135 | } 136 | -------------------------------------------------------------------------------- /src/stories/assets/lotr.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "timestamp":"12.04.3018", 4 | "character":"Gandalf", 5 | "text":"Gandalf reaches Hobbiton. Tells Frodo about the ring." 6 | }, 7 | { 8 | "timestamp":"12.04.3018", 9 | "character":"Frodo", 10 | "text":"Gandalf tells Frodo about the ring." 11 | }, 12 | { 13 | "timestamp":"30.06.3018", 14 | "character":"Gandalf", 15 | "text":"Gandalf leaves Hobbiton. Leaves for Isengard." 16 | }, 17 | { 18 | "timestamp":"20.07.3018", 19 | "character":"Gandalf", 20 | "text":"Gandalf imprisoned in Orthanc by Saruman." 21 | }, 22 | { 23 | "timestamp":"18.09.3018", 24 | "character":"Gandalf", 25 | "text":"Gandalf escapes from Orthanc." 26 | }, 27 | { 28 | "timestamp":"19.09.3018", 29 | "character":"Gandalf", 30 | "text":"Gandalf comes to Edoras." 31 | }, 32 | { 33 | "timestamp":"22.09.3018", 34 | "character":"Frodo", 35 | "text":"Frodo's birthday party." 36 | }, 37 | { 38 | "timestamp":"23.09.3018", 39 | "character":"Frodo", 40 | "text":"Fatty and Merry leave in the morning. Frodo, Sam and Pippin in the evening." 41 | }, 42 | { 43 | "timestamp":"23.09.3018", 44 | "character":"Gandalf", 45 | "text":"Gandalf tames Shadowfax and rides to Hobbiton." 46 | }, 47 | { 48 | "timestamp":"25.09.3018", 49 | "character":"Frodo", 50 | "text":"The hobbits reunite in Crickhollow." 51 | }, 52 | { 53 | "timestamp":"26.09.3018", 54 | "character":"Frodo", 55 | "text":"The hobbits stay with Tom Bombadil." 56 | }, 57 | { 58 | "timestamp":"28.09.3018", 59 | "character":"Frodo", 60 | "text":"Captured by Barrow-wight. Rescued by Tom Bombadil." 61 | }, 62 | { 63 | "timestamp":"29.09.3018", 64 | "character":"Gandalf", 65 | "text":"Gandalf reaches Hobbiton." 66 | }, 67 | { 68 | "timestamp":"29.09.3018", 69 | "character":"Frodo", 70 | "text":"Arrival at Bree. Meeting with Strider." 71 | }, 72 | { 73 | "timestamp":"30.09.3018", 74 | "character":"Frodo", 75 | "text":"Hobbits and Strider leave Bree in the morning." 76 | }, 77 | { 78 | "timestamp":"01.10.3018", 79 | "character":"Gandalf", 80 | "text":"Gandalf leaves Bree." 81 | }, 82 | { 83 | "timestamp":"03.10.3018", 84 | "character":"Gandalf", 85 | "text":"Gandalf attacked on Weathertop." 86 | }, 87 | { 88 | "timestamp":"06.10.3018", 89 | "character":"Frodo", 90 | "text":"Camp attacked at night. Frodo wounded." 91 | }, 92 | { 93 | "timestamp":"13.10.3018", 94 | "character":"Frodo", 95 | "text":"Group crosses the bridge of Mithreidel." 96 | }, 97 | { 98 | "timestamp":"18.10.3018", 99 | "character":"Gandalf", 100 | "text":"Gandalf reaches Rivendell." 101 | }, 102 | { 103 | "timestamp":"18.10.3018", 104 | "character":"Frodo", 105 | "text":"Glorfindel finds Frodo at dusk." 106 | }, 107 | { 108 | "timestamp":"20.10.3018", 109 | "character":"Frodo", 110 | "text":"Frodo escapes the Ford of Bruinen. Arrival in Rivendell." 111 | }, 112 | { 113 | "timestamp":"24.10.3018", 114 | "character":"Frodo", 115 | "text":"Frodo awakes in Rivendell." 116 | }, 117 | { 118 | "timestamp":"25.10.3018", 119 | "character":"Gandalf", 120 | "text":"The Council of Elrond." 121 | }, 122 | { 123 | "timestamp":"25.10.3018", 124 | "character":"Frodo", 125 | "text":"The Council of Elrond." 126 | }, 127 | { 128 | "timestamp":"31.12.3018", 129 | "character":"Gandalf", 130 | "text":"The fellowship leaves Rivendell at dawn." 131 | }, 132 | { 133 | "timestamp":"31.12.3018", 134 | "character":"Frodo", 135 | "text":"The fellowship leaves Rivendell at dawn." 136 | } 137 | ] 138 | -------------------------------------------------------------------------------- /src/stories/assets/milestones-with-ids.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "timestamp":"2017-08-22T00:00", 4 | "detail":"v1.0.0-alpha1", 5 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha1", 6 | "customId":"milestone-v1-0-0-alpha1" 7 | }, 8 | { 9 | "timestamp":"2017-08-24T00:00", 10 | "detail":"v1.0.0-alpha2", 11 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha2", 12 | "customId":"milestone-v1-0-0-alpha2" 13 | }, 14 | { 15 | "timestamp":"2017-08-30T00:00", 16 | "detail":"v1.0.0-alpha3", 17 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha3", 18 | "customId":"milestone-v1-0-0-alpha3" 19 | }, 20 | { 21 | "timestamp":"2017-09-03T00:00", 22 | "detail":"v1.0.0-alpha4", 23 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha4", 24 | "customId":"milestone-v1-0-0-alpha4" 25 | }, 26 | { 27 | "timestamp":"2017-09-06T00:00", 28 | "detail":"v1.0.0-alpha5", 29 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha5", 30 | "customId":"milestone-v1-0-0-alpha5" 31 | }, 32 | { 33 | "timestamp":"2017-10-16T00:00", 34 | "detail":"v1.0.0-alpha6", 35 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha6", 36 | "customId":"milestone-v1-0-0-alpha6" 37 | }, 38 | { 39 | "timestamp":"2017-11-02T00:00", 40 | "detail":"v1.0.0-alpha7", 41 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha7", 42 | "customId":"milestone-v1-0-0-alpha7" 43 | }, 44 | { 45 | "timestamp":"2017-11-03T00:00", 46 | "detail":"v1.0.0-alpha8", 47 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha8", 48 | "customId":"milestone-v1-0-0-alpha8" 49 | }, 50 | { 51 | "timestamp":"2018-01-05T00:00", 52 | "detail":"v1.0.0-alpha9", 53 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha9", 54 | "customId":"milestone-v1-0-0-alpha9" 55 | }, 56 | { 57 | "timestamp":"2018-01-30T00:00", 58 | "detail":"v1.0.0-alpha10", 59 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha10", 60 | "customId":"milestone-v1-0-0-alpha10" 61 | }, 62 | { 63 | "timestamp":"2018-02-19T00:00", 64 | "detail":"v1.0.0-alpha11", 65 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha11", 66 | "customId":"milestone-v1-0-0-alpha11" 67 | }, 68 | { 69 | "timestamp":"2018-03-28T00:00", 70 | "detail":"v1.0.0-alpha12", 71 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha12", 72 | "customId":"milestone-v1-0-0-alpha12" 73 | }, 74 | { 75 | "timestamp":"2018-04-27T00:00", 76 | "detail":"v1.0.0-alpha13", 77 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha13", 78 | "customId":"milestone-v1-0-0-alpha13" 79 | }, 80 | { 81 | "timestamp":"2020-02-18T00:00", 82 | "detail":"v1.0.0-alpha14", 83 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha14", 84 | "customId":"milestone-v1-0-0-alpha14" 85 | }, 86 | { 87 | "timestamp":"2020-03-14T00:00", 88 | "detail":"v1.0.0-beta1", 89 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-beta1", 90 | "customId":"milestone-v1-0-0-beta1" 91 | }, 92 | { 93 | "timestamp":"2020-03-17T00:00", 94 | "detail":"v1.0.0-beta2", 95 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-beta2", 96 | "customId":"milestone-v1-0-0-beta2" 97 | }, 98 | { 99 | "timestamp":"2020-04-14T00:00", 100 | "detail":"v1.0.0", 101 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0", 102 | "customId":"milestone-v1-0-0" 103 | } 104 | ] -------------------------------------------------------------------------------- /src/stories/assets/vikings.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "year":789, 4 | "title":"Vikings begin attacks on England.", 5 | "id":"789-attacks-england" 6 | }, 7 | { 8 | "year":800, 9 | "title":"The Oseberg Viking longship buried", 10 | "id":"800-oseberg-longship" 11 | }, 12 | { 13 | "year":840, 14 | "title":"Vikings found Dublin in Ireland.", 15 | "id":"840-dublin-ireland" 16 | }, 17 | { 18 | "year":844, 19 | "title":"A Viking raid on Seville is repulsed.", 20 | "id":"844-seville-raid" 21 | }, 22 | { 23 | "year":860, 24 | "title":"Rus Vikings attack Constantinople.", 25 | "id":"860-constantinople-attack" 26 | }, 27 | { 28 | "year":862, 29 | "title":"Novgorod in Russia is founded by the Rus Viking, Ulrich.", 30 | "id":"862-novgorod-founded" 31 | }, 32 | { 33 | "year":866, 34 | "title":"Danish Vikings establish a kingdom in York, England.", 35 | "id":"866-york-kingdom" 36 | }, 37 | { 38 | "year":871, 39 | "title":"Danish advance is halted in England.", 40 | "id":"871-danish-halted" 41 | }, 42 | { 43 | "year":872, 44 | "title":"Harald I gains control of Norway.", 45 | "id":"872-harald-norway" 46 | }, 47 | { 48 | "year":879, 49 | "title":"Rurik establishes Kiev as the center of the Kievan Rus' domains.", 50 | "id":"879-kiev-established" 51 | }, 52 | { 53 | "year":886, 54 | "title":"Alfred divides England with the Danes under the Danelaw pact.", 55 | "id":"886-danelaw-pact" 56 | }, 57 | { 58 | "year":900, 59 | "title":"The Vikings raid along the Mediterranean coast.", 60 | "id":"900-mediterranean-raids" 61 | }, 62 | { 63 | "year":911, 64 | "title":"The Viking chief Rollo founds Normandy in France.", 65 | "id":"911-normandy-founded" 66 | }, 67 | { 68 | "year":941, 69 | "title":"Rus Vikings attack Constantinople(Istanbul).", 70 | "id":"941-constantinople-attack" 71 | }, 72 | { 73 | "year":981, 74 | "title":"Viking leader Erik the Red discovers Greenland.", 75 | "id":"981-greenland-discovery" 76 | }, 77 | { 78 | "year":986, 79 | "title":"Viking ships sail in Newfoundland waters.", 80 | "id":"986-newfoundland" 81 | }, 82 | { 83 | "year":995, 84 | "title":"Olav I conquers Norway and proclaims it a Christian kingdom.", 85 | "id":"995-olav-norway" 86 | }, 87 | { 88 | "year":1000, 89 | "title":"Christianity reaches Greenland and Iceland.", 90 | "id":"1000-christianity-iceland" 91 | }, 92 | { 93 | "year":1000, 94 | "title":"Leif Eriksson, explores the coast of North America.", 95 | "id":"1000-leif-north-america" 96 | }, 97 | { 98 | "year":1000, 99 | "title":"Olav I dies; Norway is ruled by the Danes.", 100 | "id":"1000-olav-dies" 101 | }, 102 | { 103 | "year":1002, 104 | "title":"Brian Boru defeats the Norse and becomes the king of Ireland.", 105 | "id":"1002-brian-boru" 106 | }, 107 | { 108 | "year":1013, 109 | "title":"The Danes conquer England; Æthelred flees to Normandy.", 110 | "id":"1013-danes-conquer-england" 111 | }, 112 | { 113 | "year":1015, 114 | "title":"Vikings abandon the Vinland settlement on the coast of North America.", 115 | "id":"1015-vinland-abandoned" 116 | }, 117 | { 118 | "year":1016, 119 | "title":"Olav II regains Norway from the Danes.", 120 | "id":"1016-olav-norway" 121 | }, 122 | { 123 | "year":1016, 124 | "title":"The Danes under Knut(Canute) rule England.", 125 | "id":"1016-knut-england" 126 | }, 127 | { 128 | "year":1028, 129 | "title":"Knut(Canute), king of England and Denmark, conquers Norway.", 130 | "id":"1028-knut-norway" 131 | }, 132 | { 133 | "year":1042, 134 | "title":"Edward the Confessor rules England with the support of the Danes.", 135 | "id":"1042-edward-confessor" 136 | }, 137 | { 138 | "year":1050, 139 | "title":"The city of Oslo is founded in Norway.", 140 | "id":"1050-oslo-founded" 141 | }, 142 | { 143 | "year":1066, 144 | "title":"Battle of Stamford Bridge", 145 | "id":"1066-stamford-bridge" 146 | }, 147 | { 148 | "year":1066, 149 | "title":"Battle of Hastings.", 150 | "id":"1066-hastings" 151 | } 152 | ] -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [`main`](https://github.com/walterra/d3-milestones/tree/main) 2 | 3 | ## 1.6.0 4 | 5 | ### Minor Changes 6 | 7 | - 78c1b6c: Add categoryStyle mapping to style category labels and fix spacing calculation when multiple items share same timestamp 8 | - 4fbafeb: Add bulletStyle mapping for custom bullet colors and sizes, and add font-size examples to textStyle Storybook demonstration 9 | 10 | ### Patch Changes 11 | 12 | - 5528658: Fix release workflow to support headless browser testing 13 | 14 | No public interface changes since `v1.5.0`. 15 | 16 | ## [`v1.5.0`](https://github.com/walterra/d3-milestones/tree/v1.5.0) 17 | 18 | - Added support for ordinal scales as an alternative to time scales. (57c01be) ([#15](https://github.com/walterra/d3-milestones/issues/15)) 19 | - Add `renderCallback()` method to apply customizations after rendering is complete. (c7d5c0f) ([#79](https://github.com/walterra/d3-milestones/issues/79)) 20 | - Added support for custom HTML ID attributes for milestone elements. (a82b00a) ([#78](https://github.com/walterra/d3-milestones/issues/78)) 21 | - Added WebP format to the list of supported image formats. (1ac8f55) ([#78](https://github.com/walterra/d3-milestones/issues/78)) 22 | 23 | ## [`v1.4.7`](https://github.com/walterra/d3-milestones/tree/v1.4.7) 24 | 25 | - Update `.nvmrc` to `16.18` (243ef09). 26 | 27 | ## [`v1.4.6`](https://github.com/walterra/d3-milestones/tree/v1.4.6) 28 | 29 | - Update `.nvmrc` to `16.17` (6cdf96e). 30 | 31 | ## [`v1.4.5`](https://github.com/walterra/d3-milestones/tree/v1.4.5) 32 | 33 | - Update `.nvmrc` to `16.16` (da77577). 34 | 35 | ## [`v1.4.4`](https://github.com/walterra/d3-milestones/tree/v1.4.4) 36 | 37 | - Dependency updates. 38 | 39 | ## [`v1.4.3`](https://github.com/walterra/d3-milestones/tree/v1.4.3) 40 | 41 | - Update `.nvmrc` to `16.14`. 42 | - Fixes Karma setup. 43 | - Switched functional tests to use Firefox instead of Chrome. 44 | - Switched to Babel from Buble. 45 | - Updates `d3` modules. 46 | 47 | ## [`v1.4.2`](https://github.com/walterra/d3-milestones/tree/v1.4.2) 48 | 49 | - Fix `autoResize` if passed in `selector` is already an element and not just a string. (a25b41a) 50 | 51 | ## [`v1.4.1`](https://github.com/walterra/d3-milestones/tree/v1.4.1) 52 | 53 | - Fix `autoResize` to consider wrapping element instead of overall browser window. ([#62](https://github.com/walterra/d3-milestones/issues/62)) 54 | 55 | ## [`v1.4.0`](https://github.com/walterra/d3-milestones/tree/v1.4.0) 56 | 57 | - Support for custom styles for text elements. ([#11](https://github.com/walterra/d3-milestones/issues/11)) 58 | 59 | ## [`v1.3.0`](https://github.com/walterra/d3-milestones/tree/v1.3.0) 60 | 61 | - Expose option `urlTarget` to be able to set the `target` attribute when labels are rendered as links. ([#44](https://github.com/walterra/d3-milestones/issues/44)) 62 | 63 | ## [`v1.2.2`](https://github.com/walterra/d3-milestones/tree/v1.2.2) 64 | 65 | - Optimize layout for last item. (6f7ab03) 66 | 67 | ## [`v1.2.1`](https://github.com/walterra/d3-milestones/tree/v1.2.1) 68 | 69 | - Fix `autoResize` on load. Improved defaults handling. ([#47](https://github.com/walterra/d3-milestones/issues/47)) 70 | 71 | ## [`v1.2.0`](https://github.com/walterra/d3-milestones/tree/v1.2.0) 72 | 73 | - Expose `autoResize` as an option. ([#46](https://github.com/walterra/d3-milestones/issues/46)) 74 | - Fixes stale event listeners. ([#45](https://github.com/walterra/d3-milestones/issues/45)) 75 | 76 | ## [`v1.1.0`](https://github.com/walterra/d3-milestones/tree/v1.1.0) 77 | 78 | - Support for labels to be displayed as links ([#31](https://github.com/walterra/d3-milestones/issues/31)) 79 | 80 | ## [`v1.0.1`](https://github.com/walterra/d3-milestones/tree/v1.0.1) 81 | 82 | - Fix build setup to no longer require `npx-force-resolutions` ([#27](https://github.com/walterra/d3-milestones/issues/27)) 83 | 84 | ## [`v1.0.0`](https://github.com/walterra/d3-milestones/tree/v1.0.0) 85 | 86 | - Layout optimizations ([#16](https://github.com/walterra/d3-milestones/issues/16)) 87 | 88 | ## [`v1.0.0-beta2`](https://github.com/walterra/d3-milestones/tree/v1.0.0-beta2) 89 | 90 | - Fixes vertical orientation when used with multiple categories ([#23](https://github.com/walterra/d3-milestones/issues/23)) 91 | - Adds documentation for label distribution in `README.md`. 92 | - Updated project setup to include `jest` for unit tests and `prettier` for code formatting. 93 | 94 | ## [`v1.0.0-beta1`](https://github.com/walterra/d3-milestones/tree/v1.0.0-beta1) 95 | 96 | - Added an option to switch between horizontal and vertical orientation of the timeline ([#1](https://github.com/walterra/d3-milestones/issues/1)) 97 | 98 | ## [`v1.0.0-alpha14`](https://github.com/walterra/d3-milestones/tree/v1.0.0-alpha14) 99 | 100 | - Added Math.round on the x.range to accommodate widths with decimal. Used in Grid Layouts with rows and columns. ([#10](https://github.com/walterra/d3-milestones/pull/10)) Thanks @jelohipolitocruz 101 | 102 | ## [`v1.0.0-alpha13`](https://github.com/walterra/d3-milestones/tree/v1.0.0-alpha13) 103 | 104 | - Fixes an issue where resizing would result in multiple label elements ([#7](https://github.com/walterra/d3-milestones/pull/7)). Thanks @avborhanian 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-milestones", 3 | "version": "1.6.0", 4 | "description": "A d3 based timeline visualization.", 5 | "keywords": [ 6 | "d3", 7 | "visualization", 8 | "timeline" 9 | ], 10 | "main": "build/d3-milestones.js", 11 | "module": "src/main", 12 | "jsnext:main": "src/main", 13 | "homepage": "https://walterra.github.io/d3-milestones", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/walterra/d3-milestones.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/walterra/d3-milestones/issues" 20 | }, 21 | "license": "Apache-2.0", 22 | "contributors": [ 23 | "Walter Rafelsberger " 24 | ], 25 | "publishConfig": { 26 | "access": "public", 27 | "registry": "https://registry.npmjs.org", 28 | "provenance": true 29 | }, 30 | "engines": { 31 | "node": ">=20.0.0" 32 | }, 33 | "dependencies": { 34 | "d3-array": "^3.2.0", 35 | "d3-collection": "^1.0.7", 36 | "d3-scale": "^4.0.2", 37 | "d3-selection": "^3.0.0", 38 | "d3-time-format": "^4.1.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.26.10", 42 | "@babel/plugin-transform-modules-commonjs": "^7.26.3", 43 | "@babel/preset-env": "^7.26.9", 44 | "@changesets/cli": "^2.29.8", 45 | "@chromatic-com/storybook": "^1", 46 | "@elastic/apm-rum": "^5.12.0", 47 | "@rollup/plugin-babel": "^6.0.3", 48 | "@rollup/plugin-eslint": "^9.0.5", 49 | "@rollup/plugin-node-resolve": "^11.1.0", 50 | "@storybook/addon-actions": "^8.6.12", 51 | "@storybook/addon-essentials": "^8.6.12", 52 | "@storybook/addon-links": "^8.6.12", 53 | "@storybook/addon-mdx-gfm": "^8.6.12", 54 | "@storybook/addon-webpack5-compiler-babel": "^3.0.3", 55 | "@storybook/html": "^8.6.12", 56 | "@storybook/html-webpack5": "^8.6.12", 57 | "acorn": "^7.1.1", 58 | "babel-jest": "^29.3.1", 59 | "babel-loader": "^9.1.0", 60 | "browserify": "^17.0.1", 61 | "css-loader": "^6.5.1", 62 | "eslint": "^7.16.0", 63 | "eslint-config-es5": "^0.5.0", 64 | "eslint-config-prettier": "^7.1.0", 65 | "eslint-plugin-jest": "^24.1.3", 66 | "eslint-plugin-prettier": "^3.3.0", 67 | "eslint-plugin-storybook": "^0.8.0", 68 | "gh-pages": "^6.1.1", 69 | "jest": "^29.3.1", 70 | "jest-environment-jsdom": "^29.7.0", 71 | "karma": "^6.4.4", 72 | "karma-firefox-launcher": "^2.1.3", 73 | "karma-rollup-preprocessor": "^7.0.8", 74 | "karma-tap": "^4.2.0", 75 | "karma-tap-pretty-reporter": "^4.2.0", 76 | "less": "^4.1.2", 77 | "less-loader": "^10.2.0", 78 | "npm-watch": "^0.11.0", 79 | "prettier": "^2.2.1", 80 | "react": "^18.3.1", 81 | "react-dom": "^18.3.1", 82 | "regenerator-runtime": "^0.13.11", 83 | "rollup": "^4.40.0", 84 | "serve": "^13.0.2", 85 | "storybook": "^8.6.12", 86 | "style-loader": "^3.3.1", 87 | "tap-spec": "^5.0.0", 88 | "tape": "^5.6.1", 89 | "tape-promise": "^4.0.0", 90 | "uglify-js": "^3.9.3", 91 | "webpack": "^5.99.7" 92 | }, 93 | "scripts": { 94 | "start": "serve .", 95 | "build": "rm -rf build && mkdir build && rollup -c && yarn build-css", 96 | "pretest": "browserify test/tape.js --standalone tape -o build/tape.js", 97 | "watch": "rm -rf build && mkdir build && rollup -c --watch | yarn watch-css | serve .", 98 | "lint": "eslint \"./src/**/*.js\" \"./test/**/*.js\"", 99 | "test": "yarn build && yarn pretest && yarn test-jest && yarn test-karma", 100 | "test-karma": "karma start karma.config.js", 101 | "test-jest": "jest", 102 | "test-watch": "karma start karma.config.js --no-single-run", 103 | "build-css": "lessc src/styles/styles.less > build/d3-milestones.css", 104 | "watch-css": "npm-watch build-css", 105 | "prepare": "yarn build && uglifyjs build/d3-milestones.js -c -m -o build/d3-milestones.min.js && yarn build-css", 106 | "prepublishOnly": "yarn test", 107 | "changeset": "changeset", 108 | "version-packages": "changeset version", 109 | "release": "yarn build && uglifyjs build/d3-milestones.js -c -m -o build/d3-milestones.min.js && yarn build-css && changeset publish", 110 | "postpublish": "zip -j build/d3-milestones.zip -- LICENSE.md README.md build/d3-milestones.css build/d3-milestones.js build/d3-milestones.min.js", 111 | "storybook": "storybook dev -p 6006", 112 | "predeploy": "yarn build-storybook", 113 | "deploy-storybook": "gh-pages -d storybook-static", 114 | "build-storybook": "storybook build" 115 | }, 116 | "watch": { 117 | "build-css": { 118 | "patterns": [ 119 | "src/styles" 120 | ], 121 | "extensions": "less" 122 | } 123 | }, 124 | "resolutions": { 125 | "async": "2.6.4", 126 | "body-parser": "1.20.3", 127 | "cached-path-relative": "1.1.0", 128 | "cookie": "0.7.0", 129 | "decode-uri-component": "0.2.2", 130 | "elliptic": "6.6.1", 131 | "engine.io": "6.4.2", 132 | "follow-redirects": "1.15.6", 133 | "glob-parent": "5.1.2", 134 | "loader-utils": "1.4.2", 135 | "lodash": "4.17.21", 136 | "minimatch": "3.1.2", 137 | "minimist": "1.2.6", 138 | "nanoid": "3.3.4", 139 | "nodemon": "2.0.20", 140 | "node-fetch": "2.6.7", 141 | "normalize-url": "4.5.1", 142 | "path-parse": "1.0.7", 143 | "plist": "3.0.5", 144 | "qs": "6.10.3", 145 | "socket.io-parser": "4.2.3", 146 | "tap-out": "3.0.0", 147 | "terser": "4.8.1", 148 | "trim": "0.0.3", 149 | "ua-parser-js": ">=0.7.24", 150 | "ws": ">=7.4.6", 151 | "x-default-browser": "0.5.2", 152 | "wrap-ansi": "7.0.0", 153 | "string-width": "4.1.0", 154 | "semver": ">=7.5.2", 155 | "webpack": "5.99.7" 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/stories/assets/milestones.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "timestamp":"2017-08-22T00:00", 4 | "detail":"v1.0.0-alpha1", 5 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha1" 6 | }, 7 | { 8 | "timestamp":"2017-08-24T00:00", 9 | "detail":"v1.0.0-alpha2", 10 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha2" 11 | }, 12 | { 13 | "timestamp":"2017-08-24T00:00", 14 | "detail":"v1.0.0-alpha3", 15 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha3" 16 | }, 17 | { 18 | "timestamp":"2017-09-27T00:00", 19 | "detail":"v1.0.0-alpha4", 20 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha4" 21 | }, 22 | { 23 | "timestamp":"2017-09-28T00:00", 24 | "detail":"v1.0.0-alpha5", 25 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha5" 26 | }, 27 | { 28 | "timestamp":"2018-01-02T00:00", 29 | "detail":"v1.0.0-alpha6", 30 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha6" 31 | }, 32 | { 33 | "timestamp":"2018-01-03T00:00", 34 | "detail":"v1.0.0-alpha7", 35 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha7" 36 | }, 37 | { 38 | "timestamp":"2018-01-03T00:00", 39 | "detail":"v1.0.0-alpha8", 40 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha8" 41 | }, 42 | { 43 | "timestamp":"2018-02-08T00:00", 44 | "detail":"v1.0.0-alpha9", 45 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha9" 46 | }, 47 | { 48 | "timestamp":"2018-02-08T00:00", 49 | "detail":"v1.0.0-alpha10", 50 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha10" 51 | }, 52 | { 53 | "timestamp":"2018-10-25T00:00", 54 | "detail":"v1.0.0-alpha11", 55 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha11" 56 | }, 57 | { 58 | "timestamp":"2018-11-27T00:00", 59 | "detail":"v1.0.0-alpha12", 60 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha12" 61 | }, 62 | { 63 | "timestamp":"2019-05-27T00:00", 64 | "detail":"v1.0.0-alpha13", 65 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha13" 66 | }, 67 | { 68 | "timestamp":"2019-08-27T00:00", 69 | "detail":"v1.0.0-alpha14", 70 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-alpha14" 71 | }, 72 | { 73 | "timestamp":"2020-05-29T00:00", 74 | "detail":"v1.0.0-beta1", 75 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-beta1" 76 | }, 77 | { 78 | "timestamp":"2021-01-01T00:00", 79 | "detail":"v1.0.0-beta2", 80 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0-beta2" 81 | }, 82 | { 83 | "timestamp":"2021-01-07T00:00", 84 | "detail":"v1.0.0", 85 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.0" 86 | }, 87 | { 88 | "timestamp":"2021-01-18T00:00", 89 | "detail":"v1.0.1", 90 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.0.1" 91 | }, 92 | { 93 | "timestamp":"2021-04-02T00:00", 94 | "detail":"v1.1.0", 95 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.1.0" 96 | }, 97 | { 98 | "timestamp":"2021-12-29T00:00", 99 | "detail":"v1.2.0", 100 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.2.0" 101 | }, 102 | { 103 | "timestamp":"2021-12-30T00:00", 104 | "detail":"v1.2.1", 105 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.2.1" 106 | }, 107 | { 108 | "timestamp":"2021-12-30T00:00", 109 | "detail":"v1.2.2", 110 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.2.2" 111 | }, 112 | { 113 | "timestamp":"2022-07-05T00:00", 114 | "detail":"v1.3.0", 115 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.3.0" 116 | }, 117 | { 118 | "timestamp":"2022-07-05T00:00", 119 | "detail":"v1.4.0", 120 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.0" 121 | }, 122 | { 123 | "timestamp":"2022-07-06T00:00", 124 | "detail":"v1.4.1", 125 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.1" 126 | }, 127 | { 128 | "timestamp":"2022-07-06T00:00", 129 | "detail":"v1.4.2", 130 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.2" 131 | }, 132 | { 133 | "timestamp":"2023-01-05T00:00", 134 | "detail":"v1.4.3", 135 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.3" 136 | }, 137 | { 138 | "timestamp":"2023-01-05T00:00", 139 | "detail":"v1.4.4", 140 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.4" 141 | }, 142 | { 143 | "timestamp":"2023-01-05T00:00", 144 | "detail":"v1.4.5", 145 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.5" 146 | }, 147 | { 148 | "timestamp":"2023-01-05T00:00", 149 | "detail":"v1.4.6", 150 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.6" 151 | }, 152 | { 153 | "timestamp":"2023-01-05T00:00", 154 | "detail":"v1.4.7", 155 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.4.7" 156 | }, 157 | { 158 | "timestamp":"2025-04-27T00:00", 159 | "detail":"v1.5.0", 160 | "giturl":"https://github.com/walterra/d3-milestones/releases/tag/v1.5.0" 161 | } 162 | ] 163 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build Commands 6 | 7 | - Build: `yarn build` 8 | - Start dev server: `yarn start` or `yarn watch` 9 | - Storybook: `yarn storybook` 10 | 11 | ## Test Commands 12 | 13 | - All tests: `yarn test` 14 | - Jest tests only: `yarn test-jest` 15 | - Single Jest test: `npx jest src/path/to/file.test.js` 16 | - Karma tests: `yarn test-karma` 17 | - Watch tests: `yarn test-watch` 18 | 19 | ## Lint Commands 20 | 21 | - Lint code: `yarn lint` 22 | - Try `yarn lint --fix` before fixing linting errors manually 23 | 24 | ## Release Commands 25 | 26 | - Add changeset: `yarn changeset` 27 | - Version packages: `yarn version-packages` 28 | - Publish release: `yarn release` 29 | 30 | ## Code Style Guidelines 31 | 32 | - Use ES6 modules with named exports 33 | - Follow ESLint and Prettier rules defined in .eslintrc.json 34 | - Write Jest tests for new functionality 35 | - Use kebab-case for filenames (with leading underscore for internal modules) 36 | - Follow D3.js conventions and API patterns 37 | - Add changeset for all user-facing changes (`yarn changeset`) 38 | - Keep code backward compatible when possible 39 | 40 | ## Architecture Overview 41 | 42 | ### Core Entry Point 43 | 44 | - `src/main.js` - Main factory function that creates timeline instances and orchestrates all functionality 45 | 46 | ### Scale Types 47 | 48 | The library supports two scale types (configurable via `.scaleType()`): 49 | 50 | - `time` - Uses D3 time scale for chronological data with timestamps 51 | - `ordinal` - Uses D3 ordinal scale for categorical data without timestamps 52 | 53 | ### Internal Modules (prefixed with underscore) 54 | 55 | - `_defaults.js` - Default configuration values for all timeline options 56 | - `_api.js` - API factory that creates the fluent interface for method chaining 57 | - `_transform.js` - Data transformation logic to prepare raw data for rendering 58 | - `_optimize.js` - Label overlap detection and vertical displacement algorithm 59 | - `_time_parse.js` - Time parsing utilities wrapping D3 time-format 60 | - `_time_format.js` - Time formatting utilities wrapping D3 time-format 61 | - `_aggregate_formats.js` - Time format strings for different aggregation levels 62 | - `_css.js` - CSS class name constants 63 | - `_is_above.js` - Determines label distribution (top/bottom or left/right) 64 | - `_get_available_width.js` - Calculates available width for labels 65 | - `_get_next_group_height.js` - Calculates vertical spacing for groups 66 | - `_get_attribute.js` - Utility to safely extract attributes from data objects 67 | 68 | ### Rendering Flow 69 | 70 | 1. User calls `milestones(selector)` to create timeline instance 71 | 2. User configures via chained methods (`.mapping()`, `.scaleType()`, `.orientation()`, etc.) 72 | 3. User calls `.render(data)` to transform data and create DOM elements 73 | 4. Data flows through `_transform.js` to create nested structure grouped by time/ordinal keys 74 | 5. D3 selections render timeline groups, bullets, and labels 75 | 6. If `optimize` is enabled, `_optimize.js` adjusts label positions to avoid overlaps 76 | 7. Optional callbacks fire: `onEventClick`, `onEventMouseOver`, `onEventMouseLeave`, `renderCallback` 77 | 78 | ### Configuration System 79 | 80 | The library uses closure-based state management. Each configuration method (e.g., `setOrientation`, `setScaleType`) updates local variables that are captured in the closure, and the public API is returned via the `api()` factory. 81 | 82 | ### Auto-resize Behavior 83 | 84 | Uses `ResizeObserver` to automatically re-render timeline when container size changes. This can be disabled with `.autoResize(false)`. 85 | 86 | ### Test Structure 87 | 88 | - Jest tests: Unit tests for individual modules (files ending in `.test.js`) 89 | - Karma tests: Browser-based integration tests in `test/*-test.js` 90 | - Both test runners use Babel to transpile ES6 modules 91 | 92 | ## Release Process 93 | 94 | Uses Changesets with automated GitHub Actions workflow. See [docs/RELEASE_PROCESS.md](docs/RELEASE_PROCESS.md) for complete details. 95 | 96 | ## Changesets 97 | 98 | **Do NOT use `yarn changeset`** - it's interactive. Create files directly: 99 | 100 | ```markdown 101 | # .changeset/.md 102 | 103 | --- 104 | 105 | "d3-milestones": patch|minor|major 106 | 107 | --- 108 | 109 | Concise single-line description for CHANGELOG.md (not implementation details) 110 | ``` 111 | 112 | **Guidelines for changeset messages:** 113 | 114 | - ✅ **Good**: "Add Jest testing infrastructure with 70% coverage thresholds and automated CI testing" 115 | - ❌ **Bad**: Listing every file changed, configuration option, or implementation detail 116 | - Focus on **user-facing value** or **high-level feature addition** 117 | - Keep it **one line** when possible (two max) 118 | - Think: "What would a user want to see in release notes?" 119 | 120 | ## Technical Writing Style 121 | 122 | For documentation, JSDoc comments, git messages, all human facing text, direct factual statements only, no filler words (very/really/quite/just/simply/basically/actually/literally/comprehensive), no hedging (probably/maybe/might/could/should), no obvious phrases (please note/it's important to/keep in mind), start with present tense verbs (fetches/calculates/returns), state what not how, one line when possible, omit self-evident type information, active voice only, remove redundant phrases (in order to→to, completely finished→finished), every adjective must add information, sentences under 20 words, if removing a word preserves meaning remove it, strip all decoration keep only information. 123 | 124 | ## Git Operation Permissions 125 | 126 | Permission for git operations applies to the immediate next action only. Each commit, 127 | branch switch, push, or PR creation requires explicit user approval. Completing a requested 128 | git operation does not authorize subsequent git operations. 129 | 130 | **When unexpected git state occurs** (wrong branch, conflicts, missing upstream, extra 131 | commits): 132 | 133 | 1. Run `git status` and `git branch` 134 | 2. Show output and explain the issue 135 | 3. Ask how to proceed 136 | 4. Wait for explicit instruction 137 | 138 | Never attempt to fix git issues (cherry-pick, rebase, branch switches) without asking 139 | first. 140 | -------------------------------------------------------------------------------- /src/main.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import milestones from './main'; 6 | 7 | // Mock ResizeObserver 8 | window.ResizeObserver = class { 9 | observe() {} 10 | unobserve() {} 11 | disconnect() {} 12 | }; 13 | 14 | describe('milestones', () => { 15 | let container; 16 | 17 | beforeEach(() => { 18 | // Set up a DOM element as a render target 19 | container = document.createElement('div'); 20 | container.id = 'container'; 21 | document.body.appendChild(container); 22 | }); 23 | 24 | afterEach(() => { 25 | // Clean up 26 | document.body.removeChild(container); 27 | container = null; 28 | }); 29 | 30 | it('should return an API with all expected methods', () => { 31 | const chart = milestones('#container'); 32 | 33 | // Test public API methods 34 | expect(typeof chart.aggregateBy).toBe('function'); 35 | expect(typeof chart.mapping).toBe('function'); 36 | expect(typeof chart.optimize).toBe('function'); 37 | expect(typeof chart.orientation).toBe('function'); 38 | expect(typeof chart.distribution).toBe('function'); 39 | expect(typeof chart.parseTime).toBe('function'); 40 | expect(typeof chart.labelFormat).toBe('function'); 41 | expect(typeof chart.urlTarget).toBe('function'); 42 | expect(typeof chart.useLabels).toBe('function'); 43 | expect(typeof chart.range).toBe('function'); 44 | expect(typeof chart.render).toBe('function'); 45 | expect(typeof chart.renderCallback).toBe('function'); 46 | expect(typeof chart.onEventClick).toBe('function'); 47 | expect(typeof chart.onEventMouseLeave).toBe('function'); 48 | expect(typeof chart.onEventMouseOver).toBe('function'); 49 | }); 50 | 51 | it('should create a basic visualization when render is called with data', () => { 52 | const chart = milestones('#container'); 53 | 54 | // Create simple test data 55 | const data = [ 56 | { text: 'Event 1', timestamp: '2023-01-01' }, 57 | { text: 'Event 2', timestamp: '2023-06-01' }, 58 | { text: 'Event 3', timestamp: '2023-12-01' }, 59 | ]; 60 | 61 | // Render the chart 62 | chart.render(data); 63 | 64 | // Assert that elements have been created in the DOM 65 | const timelineElement = document.querySelector('.milestones'); 66 | expect(timelineElement).not.toBeNull(); 67 | }); 68 | 69 | it('should execute renderCallback when render is called', () => { 70 | const chart = milestones('#container'); 71 | const mockCallback = jest.fn(); 72 | 73 | // Set the render callback 74 | chart.renderCallback(mockCallback); 75 | 76 | // Create simple test data 77 | const data = [ 78 | { text: 'Event 1', timestamp: '2023-01-01' }, 79 | { text: 'Event 2', timestamp: '2023-06-01' }, 80 | ]; 81 | 82 | // Render the chart 83 | chart.render(data); 84 | 85 | // Verify callback was called 86 | expect(mockCallback).toHaveBeenCalled(); 87 | }); 88 | 89 | it('should properly configure and use mapping options', () => { 90 | const chart = milestones('#container'); 91 | 92 | // Set custom mapping 93 | chart.mapping({ 94 | text: 'title', 95 | timestamp: 'date', 96 | }); 97 | 98 | // Create data with custom field names 99 | const data = [ 100 | { title: 'Event 1', date: '2023-01-01' }, 101 | { title: 'Event 2', date: '2023-06-01' }, 102 | ]; 103 | 104 | // Render should work with the custom mapping 105 | chart.render(data); 106 | 107 | // Check that the chart was rendered 108 | const timelineElement = document.querySelector('.milestones'); 109 | expect(timelineElement).not.toBeNull(); 110 | }); 111 | 112 | it('should change orientation when orientation method is called', () => { 113 | const chart = milestones('#container'); 114 | const spy = jest.spyOn( 115 | document.querySelector('#container'), 116 | 'innerHTML', 117 | 'set' 118 | ); 119 | 120 | // Change orientation 121 | chart.orientation('vertical'); 122 | 123 | // innerHTML should be set to empty to purge the DOM 124 | expect(spy).toHaveBeenCalledWith(''); 125 | 126 | // Create simple test data 127 | const data = [ 128 | { text: 'Event 1', timestamp: '2023-01-01' }, 129 | { text: 'Event 2', timestamp: '2023-06-01' }, 130 | ]; 131 | 132 | // Render the chart 133 | chart.render(data); 134 | 135 | // Clean up spy 136 | spy.mockRestore(); 137 | }); 138 | 139 | it('should apply textStyle including font-size to text elements', () => { 140 | const chart = milestones('#container'); 141 | 142 | // Create data with textStyle including font-size 143 | const data = [ 144 | { 145 | text: 'Normal Text', 146 | timestamp: '2023-01-01', 147 | }, 148 | { 149 | text: 'Large Text', 150 | timestamp: '2023-06-01', 151 | textStyle: { 'font-size': '20px', color: 'red' }, 152 | }, 153 | { 154 | text: 'Small Text', 155 | timestamp: '2023-12-01', 156 | textStyle: { 'font-size': '8px', 'font-weight': 'bold' }, 157 | }, 158 | ]; 159 | 160 | // Render the chart 161 | chart.render(data); 162 | 163 | // Find text elements 164 | const textElements = document.querySelectorAll('.milestones-text-label'); 165 | expect(textElements.length).toBe(3); 166 | 167 | // Check that the second element has the correct styles applied 168 | const largeTextElement = textElements[1]; 169 | expect(largeTextElement.textContent).toBe('Large Text'); 170 | expect(largeTextElement.style.fontSize).toBe('20px'); 171 | expect(largeTextElement.style.color).toBe('red'); 172 | 173 | // Check that the third element has the correct styles applied 174 | const smallTextElement = textElements[2]; 175 | expect(smallTextElement.textContent).toBe('Small Text'); 176 | expect(smallTextElement.style.fontSize).toBe('8px'); 177 | expect(smallTextElement.style.fontWeight).toBe('bold'); 178 | }); 179 | 180 | it('should apply bulletStyle to bullet elements', () => { 181 | const chart = milestones('#container'); 182 | 183 | // Create data with bulletStyle 184 | const data = [ 185 | { 186 | text: 'Default Bullet', 187 | timestamp: '2023-01-01', 188 | }, 189 | { 190 | text: 'Red Bullet', 191 | timestamp: '2023-06-01', 192 | bulletStyle: { 'background-color': 'red', 'border-color': 'darkred' }, 193 | }, 194 | { 195 | text: 'Large Blue Bullet', 196 | timestamp: '2023-12-01', 197 | bulletStyle: { 'background-color': 'blue', padding: '5px' }, 198 | }, 199 | ]; 200 | 201 | // Render the chart 202 | chart.render(data); 203 | 204 | // Find bullet elements 205 | const bulletElements = document.querySelectorAll( 206 | '.milestones__group__bullet' 207 | ); 208 | expect(bulletElements.length).toBe(3); 209 | 210 | // Check that the second bullet has the correct styles applied 211 | const redBullet = bulletElements[1]; 212 | expect(redBullet.style.backgroundColor).toBe('red'); 213 | expect(redBullet.style.borderColor).toBe('darkred'); 214 | 215 | // Check that the third bullet has the correct styles applied 216 | const blueBullet = bulletElements[2]; 217 | expect(blueBullet.style.backgroundColor).toBe('blue'); 218 | expect(blueBullet.style.padding).toBe('5px'); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /docs/RELEASE_PROCESS.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | d3-milestones uses [Changesets](https://github.com/changesets/changesets) with automated GitHub Actions workflows for secure, streamlined releases. 4 | 5 | ## Overview 6 | 7 | Releases are fully automated using: 8 | - **Changesets** - Version management and changelog generation 9 | - **GitHub Actions** - Automated publishing workflow 10 | - **OIDC** - Secure, token-less npm publishing with provenance attestations 11 | - **GitHub App** - Fine-grained authentication 12 | 13 | ## For Contributors 14 | 15 | ### Adding a Changeset 16 | 17 | When making changes that should be released: 18 | 19 | ```bash 20 | yarn changeset 21 | ``` 22 | 23 | This prompts you to: 24 | 1. Select change type: 25 | - **patch** - Bug fixes, documentation 26 | - **minor** - New features (backward compatible) 27 | - **major** - Breaking changes 28 | 2. Write a summary of the change 29 | 30 | Commit the generated changeset file with your PR. 31 | 32 | ### Example Workflow 33 | 34 | ```bash 35 | # Make your changes 36 | git checkout -b feature/my-feature 37 | 38 | # Add changeset 39 | yarn changeset 40 | # Select: minor 41 | # Summary: "Add support for custom time formats" 42 | 43 | # Commit everything 44 | git add . 45 | git commit -m "feat: add custom time format support" 46 | git push 47 | ``` 48 | 49 | ## For Maintainers 50 | 51 | ### Automated Release Flow 52 | 53 | 1. **PR with changeset merged to `main`** 54 | - GitHub Actions detects changeset 55 | - Creates/updates "Release PR" 56 | - Aggregates all pending changesets 57 | - Updates `package.json` version and `CHANGELOG.md` 58 | 59 | 2. **Review Release PR** 60 | - Verify version bump is appropriate 61 | - Review changelog entries 62 | - Check all changes are documented 63 | 64 | 3. **Merge Release PR** 65 | - Triggers automated publishing: 66 | - Runs build and tests 67 | - Publishes to npm with provenance 68 | - Creates GitHub release with tags 69 | - Uploads build artifacts (d3-milestones.zip) 70 | 71 | ### Manual Release (Emergency) 72 | 73 | ```bash 74 | yarn version-packages # Update versions 75 | yarn release # Build, test, and publish 76 | ``` 77 | 78 | ## Initial Setup 79 | 80 | Required one-time configuration for repository maintainers. 81 | 82 | ### 1. Create GitHub App 83 | 84 | Create a GitHub App for authentication: 85 | 86 | 1. Go to **GitHub Settings → Developer settings → GitHub Apps → New GitHub App** 87 | 2. Configure: 88 | - **Name**: `d3-milestones-releaser` (or similar unique name) 89 | - **Homepage URL**: Repository URL 90 | - **Webhook**: Uncheck "Active" 91 | - **Permissions**: 92 | - Repository permissions → Contents: **Read and write** 93 | - Repository permissions → Pull requests: **Read and write** 94 | 3. Click **Create GitHub App** 95 | 4. Generate a private key (downloads .pem file - save securely) 96 | 5. Note the **App ID** from the app details page 97 | 6. Install app: Settings → Install App → Install on repository 98 | 99 | ### 2. Configure Repository Secrets 100 | 101 | Add to **Repository Settings → Secrets and variables → Actions**: 102 | 103 | - **Variable** `APP_ID` 104 | - Value: Your GitHub App ID 105 | - Tab: Variables → New repository variable 106 | 107 | - **Secret** `APP_PRIVATE_KEY` 108 | - Value: Contents of the .pem file (include BEGIN/END lines) 109 | - Tab: Secrets → New repository secret 110 | 111 | ### 3. Create GitHub Environment 112 | 113 | 1. Go to **Repository Settings → Environments** 114 | 2. Click **New environment** 115 | 3. Name: `npm-publish` 116 | 4. (Optional) Add protection rules: 117 | - Required reviewers 118 | - Deployment branches: `main` only 119 | 120 | ### 4. npm Configuration 121 | 122 | No npm token required! OIDC handles authentication automatically. 123 | 124 | The package is already configured with: 125 | ```json 126 | "publishConfig": { 127 | "access": "public", 128 | "registry": "https://registry.npmjs.org", 129 | "provenance": true 130 | } 131 | ``` 132 | 133 | Provenance attestations cryptographically link the published package to its source. 134 | 135 | ## Testing the Setup 136 | 137 | Verify the release process works: 138 | 139 | ```bash 140 | # Create test changeset 141 | yarn changeset 142 | # Select: patch 143 | # Summary: "Test release workflow" 144 | 145 | # Commit and push 146 | git add .changeset/ 147 | git commit -m "test: verify release workflow" 148 | git push origin main 149 | 150 | # Monitor GitHub Actions 151 | # - Release PR should be created 152 | # - Merge Release PR to publish 153 | ``` 154 | 155 | ## Troubleshooting 156 | 157 | ### "Resource not accessible by integration" 158 | - Verify GitHub App has correct permissions 159 | - Ensure App is installed on the repository 160 | 161 | ### npm publish fails 162 | - Verify `npm-publish` environment exists 163 | - Check workflow has `id-token: write` permission 164 | - Review workflow logs for npm errors 165 | 166 | ### Build artifacts not uploaded 167 | - Verify zip file exists in `build/` directory 168 | - Check `steps.changesets.outputs.published == 'true'` 169 | - Review workflow logs for upload errors 170 | 171 | ### Release PR not created 172 | - Verify changeset files exist in `.changeset/` directory 173 | - Check GitHub Actions workflow ran successfully 174 | - Ensure workflow has permission to create PRs 175 | 176 | ## Architecture 177 | 178 | ### Files 179 | 180 | ``` 181 | .changeset/ 182 | ├── config.json # Changesets configuration 183 | └── README.md # Quick reference 184 | 185 | .github/workflows/ 186 | └── release.yml # Automated release workflow 187 | 188 | package.json 189 | ├── publishConfig # npm publishing configuration 190 | └── scripts 191 | ├── changeset # Create changeset 192 | ├── version-packages # Update versions 193 | └── release # Build and publish 194 | ``` 195 | 196 | ### Workflow Steps 197 | 198 | 1. **Generate GitHub App Token** - Secure authentication 199 | 2. **Checkout Repository** - Fetch full git history 200 | 3. **Setup Node.js** - Configure build environment 201 | 4. **Upgrade npm** - Ensure OIDC support (npm 9+) 202 | 5. **Install Dependencies** - Frozen lockfile install 203 | 6. **Get Release Version** - Extract version from changesets 204 | 7. **Create Release PR or Publish** - Changesets action 205 | 8. **Upload Release Assets** - Attach build artifacts 206 | 207 | ### Security Features 208 | 209 | - **OIDC** - Token-less npm publishing (no long-lived credentials) 210 | - **Provenance** - Cryptographic attestations linking package to source 211 | - **GitHub App** - Granular permissions vs personal access tokens 212 | - **Environment protection** - Optional review requirements 213 | - **Signed commits** - Verified git tags and releases 214 | 215 | ## Benefits 216 | 217 | - **Consistent** - Standardized process across contributors 218 | - **Secure** - No long-lived tokens, cryptographic provenance 219 | - **Automated** - Version management, changelogs, publishing 220 | - **Transparent** - Clear documentation of changes in each PR 221 | - **Traceable** - Audit trail from commit → PR → release → npm 222 | 223 | ## Commands Reference 224 | 225 | ```bash 226 | # Development 227 | yarn build # Build the library 228 | yarn test # Run all tests 229 | yarn lint # Lint code 230 | 231 | # Release 232 | yarn changeset # Add a changeset 233 | yarn version-packages # Update versions (automated) 234 | yarn release # Publish to npm (automated) 235 | ``` 236 | 237 | ## References 238 | 239 | - [Changesets Documentation](https://github.com/changesets/changesets) 240 | - [npm Provenance](https://docs.npmjs.com/generating-provenance-statements) 241 | - [GitHub App Tokens](https://github.com/actions/create-github-app-token) 242 | - [OIDC in GitHub Actions](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) 243 | - [Contributing Guidelines](../CONTRIBUTING.md) 244 | -------------------------------------------------------------------------------- /src/stories/assets/os-category-labels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "system":"Windows", 4 | "versions":[ 5 | { 6 | "year":1985, 7 | "title":"Windows 1.0" 8 | }, 9 | { 10 | "year":1987, 11 | "title":"Windows 2.0" 12 | }, 13 | { 14 | "year":1988, 15 | "title":"Windows 2.10" 16 | }, 17 | { 18 | "year":1989, 19 | "title":"Windows 2.11" 20 | }, 21 | { 22 | "year":1990, 23 | "title":"Windows 3.0" 24 | }, 25 | { 26 | "year":1991, 27 | "title":"Windows 3.0 with Multimedia Extensions" 28 | }, 29 | { 30 | "year":1992, 31 | "title":"Windows 3.1" 32 | }, 33 | { 34 | "year":1992, 35 | "title":"Windows for Workgroups 3.1" 36 | }, 37 | { 38 | "year":1993, 39 | "title":"Windows NT 3.1" 40 | }, 41 | { 42 | "year":1993, 43 | "title":"Windows for Workgroups 3.11" 44 | }, 45 | { 46 | "year":1994, 47 | "title":"Windows NT 3.5" 48 | }, 49 | { 50 | "year":1995, 51 | "title":"Windows NT 3.51" 52 | }, 53 | { 54 | "year":1995, 55 | "title":"Windows 95" 56 | }, 57 | { 58 | "year":1996, 59 | "title":"Windows NT 4.0" 60 | }, 61 | { 62 | "year":1998, 63 | "title":"Windows 98" 64 | }, 65 | { 66 | "year":1999, 67 | "title":"Windows 98 SE" 68 | }, 69 | { 70 | "year":2000, 71 | "title":"Windows 2000" 72 | }, 73 | { 74 | "year":2000, 75 | "title":"Windows ME" 76 | }, 77 | { 78 | "year":2001, 79 | "title":"Windows XP" 80 | }, 81 | { 82 | "year":2003, 83 | "title":"Windows Server 2003" 84 | }, 85 | { 86 | "year":2005, 87 | "title":"Windows XP Professional x64 Edition" 88 | }, 89 | { 90 | "year":2006, 91 | "title":"Windows Vista" 92 | }, 93 | { 94 | "year":2008, 95 | "title":"Windows Server 2008" 96 | }, 97 | { 98 | "year":2009, 99 | "title":"Windows 7" 100 | }, 101 | { 102 | "year":2012, 103 | "title":"Windows Server 2012" 104 | }, 105 | { 106 | "year":2012, 107 | "title":"Windows 8" 108 | }, 109 | { 110 | "year":2013, 111 | "title":"Windows RT" 112 | }, 113 | { 114 | "year":2015, 115 | "title":"Windows 10" 116 | }, 117 | { 118 | "year":2016, 119 | "title":"Windows Server 2016" 120 | } 121 | ] 122 | }, 123 | { 124 | "system":"Mac", 125 | "versions":[ 126 | { 127 | "year":1984, 128 | "title":"System 1.0" 129 | }, 130 | { 131 | "year":1984, 132 | "title":"System 1.1" 133 | }, 134 | { 135 | "year":1985, 136 | "title":"System 2.0" 137 | }, 138 | { 139 | "year":1985, 140 | "title":"System 2.1" 141 | }, 142 | { 143 | "year":1986, 144 | "title":"System 3.0" 145 | }, 146 | { 147 | "year":1986, 148 | "title":"System 3.1" 149 | }, 150 | { 151 | "year":1986, 152 | "title":"System 3.2" 153 | }, 154 | { 155 | "year":1987, 156 | "title":"System 4.0" 157 | }, 158 | { 159 | "year":1987, 160 | "title":"System 4.1" 161 | }, 162 | { 163 | "year":1987, 164 | "title":"System 5.0" 165 | }, 166 | { 167 | "year":1987, 168 | "title":"System 5.1" 169 | }, 170 | { 171 | "year":1988, 172 | "title":"System 6.0" 173 | }, 174 | { 175 | "year":1988, 176 | "title":"System 6.0.1" 177 | }, 178 | { 179 | "year":1988, 180 | "title":"System 6.0.2" 181 | }, 182 | { 183 | "year":1989, 184 | "title":"System 6.0.3" 185 | }, 186 | { 187 | "year":1989, 188 | "title":"System 6.0.4" 189 | }, 190 | { 191 | "year":1990, 192 | "title":"System 6.0.5" 193 | }, 194 | { 195 | "year":1990, 196 | "title":"System 6.0.6" 197 | }, 198 | { 199 | "year":1990, 200 | "title":"System 6.0.7" 201 | }, 202 | { 203 | "year":1991, 204 | "title":"System 6.0.8" 205 | }, 206 | { 207 | "year":1991, 208 | "title":"System 7.0" 209 | }, 210 | { 211 | "year":1992, 212 | "title":"System 7.1" 213 | }, 214 | { 215 | "year":1993, 216 | "title":"System 7.1.1" 217 | }, 218 | { 219 | "year":1994, 220 | "title":"System 7.1.2" 221 | }, 222 | { 223 | "year":1994, 224 | "title":"System 7.5" 225 | }, 226 | { 227 | "year":1995, 228 | "title":"System 7.5.1" 229 | }, 230 | { 231 | "year":1995, 232 | "title":"System 7.5.2" 233 | }, 234 | { 235 | "year":1996, 236 | "title":"System 7.5.3" 237 | }, 238 | { 239 | "year":1996, 240 | "title":"System 7.5.5" 241 | }, 242 | { 243 | "year":1997, 244 | "title":"Mac OS 7.6" 245 | }, 246 | { 247 | "year":1997, 248 | "title":"Mac OS 7.6.1" 249 | }, 250 | { 251 | "year":1997, 252 | "title":"Mac OS 8.0" 253 | }, 254 | { 255 | "year":1998, 256 | "title":"Mac OS 8.1" 257 | }, 258 | { 259 | "year":1998, 260 | "title":"Mac OS 8.5" 261 | }, 262 | { 263 | "year":1998, 264 | "title":"Mac OS 8.5.1" 265 | }, 266 | { 267 | "year":1999, 268 | "title":"Mac OS 8.6" 269 | }, 270 | { 271 | "year":1999, 272 | "title":"Mac OS 9.0" 273 | }, 274 | { 275 | "year":2001, 276 | "title":"Mac OS 9.1" 277 | }, 278 | { 279 | "year":2001, 280 | "title":"Mac OS 9.2" 281 | }, 282 | { 283 | "year":2001, 284 | "title":"Mac OS X 10.0" 285 | }, 286 | { 287 | "year":2001, 288 | "title":"Mac OS X 10.1" 289 | }, 290 | { 291 | "year":2002, 292 | "title":"Mac OS X 10.2" 293 | }, 294 | { 295 | "year":2003, 296 | "title":"Mac OS X 10.3" 297 | }, 298 | { 299 | "year":2005, 300 | "title":"Mac OS X 10.4" 301 | }, 302 | { 303 | "year":2007, 304 | "title":"Mac OS X 10.5" 305 | }, 306 | { 307 | "year":2009, 308 | "title":"Mac OS X 10.6" 309 | }, 310 | { 311 | "year":2011, 312 | "title":"Mac OS X 10.7" 313 | }, 314 | { 315 | "year":2012, 316 | "title":"OS X 10.8" 317 | }, 318 | { 319 | "year":2013, 320 | "title":"OS X 10.9" 321 | }, 322 | { 323 | "year":2014, 324 | "title":"OS X 10.10" 325 | }, 326 | { 327 | "year":2015, 328 | "title":"OS X 10.11" 329 | }, 330 | { 331 | "year":2016, 332 | "title":"macOS 10.12" 333 | }, 334 | { 335 | "year":2017, 336 | "title":"macOS 10.13" 337 | } 338 | ] 339 | } 340 | ] 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/d3-milestones.svg?maxAge=2592000)](https://www.npmjs.com/package/d3-milestones) 2 | [![npm](https://img.shields.io/npm/l/d3-milestones.svg?maxAge=2592000)](https://www.npmjs.com/package/d3-milestones) 3 | [![npm](https://img.shields.io/npm/dt/d3-milestones.svg?maxAge=2592000)](https://www.npmjs.com/package/d3-milestones) 4 | [![github ci](https://github.com/walterra/d3-milestones/actions/workflows/ci.yml/badge.svg)](https://github.com/walterra/d3-milestones/actions/workflows/ci.yml) 5 | 6 | # d3-milestones 7 | 8 | A d3 based timeline visualization. 9 | 10 | ![The Viking Timeline](https://github.com/walterra/d3-milestones/raw/main/src/stories/assets/vikings.png) 11 | 12 | - NPM: https://www.npmjs.com/package/d3-milestones 13 | - Github: https://github.com/walterra/d3-milestones 14 | - Storybook demos: https://walterra.github.io/d3-milestones 15 | 16 | If you're using `d3-milestones` out in the wild I'd love to see what you came up with, just ping me on [twitter.com/walterra](https://www.twitter.com/walterra). 17 | 18 | ## Installing 19 | 20 | `yarn add d3-milestones`. 21 | 22 | The most quick way to get going is to use `unpkg.com` as a CDN to include the library directly into your HTML file. 23 | 24 | ```html 25 | 26 | 27 | 28 |
29 | 30 | 46 | ``` 47 | 48 | Head over here to see this example in action: https://beta.observablehq.com/@walterra/vikings-timeline. 49 | 50 | ## Examples 51 | 52 | Examples are included using storybook: 53 | 54 | - [Vikings Timeline](https://walterra.github.io/d3-milestones/?path=/story/d3-milestones--vikings) 55 | - [Windows/macOS Timeline](https://walterra.github.io/d3-milestones/?path=/story/d3-milestones--os-category-labels) 56 | - [Ordinal Scale Example](https://walterra.github.io/d3-milestones/?path=/story/d3-milestones--ordinal-scale-example) - Demonstrates using an ordinal scale instead of time scale 57 | - [Ordinal Scale with Categories](https://walterra.github.io/d3-milestones/?path=/story/d3-milestones--ordinal-scale-categories-example) - Shows how to use ordinal scales with multiple categories 58 | 59 | ## API Reference 60 | 61 | ### General Usage 62 | 63 | To initialize *d3-milestones*, pass a DOM Id to its main factory: 64 | 65 | ```javascript 66 | const vis = milestones('#wrapper'); 67 | ``` 68 | 69 | The returned object exposes the following API: 70 | 71 | # vis.aggregateBy(interval) 72 | 73 | Sets the aggregation interval for the event data, where *interval* can be one of `second`, `minute`, `hour`, `day`, `week`, `month`, `quarter` or `year`. 74 | 75 | # vis.distribution(string) 76 | 77 | Sets the label distribution, can be `top-bottom`, `top` or `bottom`. Defaults to `top-bottom`. The options don't change for vertical layouts. `top` refers to labels on the left and `bottom` to labels on the right for that layout. 78 | 79 | # vis.mapping(configObject) 80 | 81 | Sets overrides for the default attributes for the expected data structure of an event. This defaults to: 82 | 83 | ```js 84 | { 85 | category: undefined, 86 | entries: undefined, 87 | timestamp: 'timestamp', // Used only for time based scales 88 | value: 'value', // Used only for ordinal scale values 89 | text: 'text', 90 | url: 'url', 91 | id: 'id', 92 | textStyle: undefined, // Field name for custom text styling 93 | categoryStyle: undefined, // Field name for custom category label styling 94 | bulletStyle: undefined // Field name for custom bullet styling 95 | }; 96 | ``` 97 | 98 | The method allows you to override single or multiple attributes to map them to fields in your original data with a single call like: 99 | 100 | ```js 101 | vis.mapping({ 102 | 'timestamp': 'year', 103 | 'text': 'title' 104 | }) 105 | ``` 106 | 107 | **Custom Styling Fields:** 108 | 109 | - `textStyle`: Maps to a field in your data containing an object with CSS property-value pairs for event text labels. 110 | - `categoryStyle`: Maps to a field in your data containing an object with CSS property-value pairs for category labels. 111 | - `bulletStyle`: Maps to a field in your data containing an object with CSS property-value pairs for bullets (e.g., `background-color`, `border-color`, `padding`, `border-width`). 112 | 113 | Example with custom styling: 114 | 115 | ```js 116 | vis.mapping({ 117 | 'timestamp': 'year', 118 | 'text': 'title', 119 | 'bulletStyle': 'bulletStyle', 120 | 'categoryStyle': 'categoryStyle' 121 | }) 122 | .render([ 123 | { 124 | year: 1990, 125 | title: "Custom Red Bullet", 126 | bulletStyle: { 127 | "background-color": "#E12800", 128 | "border-color": "#E12800", 129 | "padding": "10px" 130 | } 131 | } 132 | ]); 133 | ``` 134 | 135 | # vis.optimize(boolean) 136 | 137 | Enables/Disables the label optimizer. When enabled, the optimizer attempts to avoid label overlap by vertically displacing labels. 138 | 139 | # vis.autoResize(boolean) 140 | 141 | Enables/Disables auto resizing. Enabled by default, this adds listeners to resizing events of the browser window. 142 | 143 | # vis.orientation(string) 144 | 145 | Sets the orientation of the timeline, can be either `horizontal` or `vertical`. Defaults to `horizontal`. 146 | 147 | # vis.scaleType(string) 148 | 149 | Sets the scale type of the timeline, can be either `time` or `ordinal`. Defaults to `time`. 150 | 151 | - `time`: Uses a time scale for chronological data with timestamps 152 | - `ordinal`: Uses an ordinal scale for categorical data without timestamps 153 | 154 | # vis.parseTime(specifier) 155 | 156 | Specifies the formatter for the timestamp field. The specifier string is expected to resemble a format described here: https://github.com/d3/d3-time-format#locale_format 157 | 158 | # vis.labelFormat(specifier) 159 | 160 | The `labelFormat` for the time label for each milestones defaults to `'%Y-%m-%d %H:%M'`. Using `aggregateBy`, `labelFormat` will be set automatically to a reasonable format corresponding to the aggregation level. Still, this method is available to override this behavior with a custom `labelFormat`. 161 | 162 | # vis.urlTarget(string) 163 | 164 | Customizes the `target` attribute when labels are provided with a URL. Can be `_blank`, `_self`, `_parent` or `_top`. 165 | 166 | # vis.useLabels(boolean) 167 | 168 | Enables/Disables the display of labels. 169 | 170 | # vis.render([data]) 171 | 172 | When called without `data` this triggers re-rendering the existing visualization. 173 | 174 | `data` is expected to be an array of event objects with fields matching either the expected defaults (`timestamp` and `text` attribute) or the provided mapping via `.mapping()`. 175 | 176 | # vis.renderCallback(function) 177 | 178 | Sets a callback function that is executed after the visualization is fully rendered, allowing you to apply custom styling or modifications to the rendered elements. The callback is called after both initial renders and automatic re-renders due to window resizing. 179 | 180 | ```js 181 | vis.renderCallback(function() { 182 | // Apply additional customizations after rendering is complete 183 | d3.select(".milestones").style("margin-left", "10%"); 184 | }).render(data); 185 | ``` 186 | 187 | # vis.onEventClick(function) 188 | 189 | Set a callback which is executed when the text or image is clicked. 190 | 191 | ```js 192 | vis.onEventClick((d) => { 193 | console.log('click', d); 194 | alert(` 195 | ${d.text} | ${d.timestamp} 196 | ${JSON.stringify(d.attributes)} 197 | `); 198 | }) 199 | ``` 200 | 201 | # vis.onEventMouseOver(function) 202 | 203 | Set a callback which is executed when the mouse cursor is over text or image. 204 | 205 | ```js 206 | vis.onEventMouseOver((d) => { 207 | console.log('mouseover', d); 208 | }) 209 | ``` 210 | 211 | # vis.onEventMouseLeave(function) 212 | 213 | Set a callback which is executed when the mouse cursor is leaving text or image. 214 | 215 | ```js 216 | vis.onEventMouseLeave((d) => { 217 | console.log('mouseleave', d); 218 | }) 219 | ``` 220 | 221 | ## Disclaimer on DevOps/CI Observability 222 | 223 | This repository uses [Elastic APM](https://www.elastic.co/observability/application-performance-monitoring) to track performance of functional tests. It uses `@elastic/apm-rum` as a `devDependency` to be run only as part of the tests. No telemetry library is part of the published package. 224 | 225 | 226 | ## More 227 | 228 | `d3-milestones` is also available as a visualization plugin for Kibana here: https://github.com/walterra/kibana-milestones-vis 229 | -------------------------------------------------------------------------------- /src/_optimize.js: -------------------------------------------------------------------------------- 1 | import * as dom from 'd3-selection'; 2 | import { nest } from 'd3-collection'; 3 | 4 | import { cssLastClass } from './_css'; 5 | import { getAttribute } from './_get_attribute'; 6 | import { getAvailableWidth } from './_get_available_width'; 7 | import { getNextGroupHeight } from './_get_next_group_height'; 8 | import { isAbove } from './_is_above'; 9 | 10 | const MAX_OPTIMIZER_RUNS = 20; 11 | 12 | const getIntValueFromPxAttribute = (domElement, attribute) => { 13 | return parseInt(domElement.style(attribute).replace('px', ''), 10); 14 | }; 15 | 16 | const getParentElement = (domElement) => 17 | domElement.select(function () { 18 | return this.parentNode; 19 | }); 20 | 21 | const isSameDistribution = (index, nextCheck, overlapCheckIndex) => { 22 | const itemRowCheck = index % nextCheck; 23 | const distributionCheck = (overlapCheckIndex + itemRowCheck) % nextCheck; 24 | 25 | return distributionCheck !== 0; 26 | }; 27 | 28 | export const optimize = ( 29 | aggregateFormatParse, 30 | distribution, 31 | labelMaxWidth, 32 | mapping, 33 | nestedData, 34 | orientation, 35 | textMerge, 36 | width, 37 | widthAttribute, 38 | x, 39 | scaleType = 'time' // Default to time scale if not provided 40 | ) => { 41 | const nestedNodes = nest() 42 | .key((d) => { 43 | return dom.selectAll(d).data()[0].timelineIndex; 44 | }) 45 | .entries(textMerge._groups); 46 | 47 | const nextCheck = distribution === 'top-bottom' ? 2 : 1; 48 | 49 | const runOptimizer = (optimizerRuns) => { 50 | let updated = 0; 51 | 52 | nestedNodes.forEach((d) => { 53 | const nodes = d.values; 54 | nodes.forEach((node) => { 55 | const d = dom.selectAll(node).data()[0]; 56 | 57 | const offsetComparator = orientation === 'horizontal' ? 60 : 20; 58 | 59 | const index = 60 | orientation === 'horizontal' ? nodes.length - d.index - 1 : d.index; 61 | 62 | const item = dom.selectAll(nodes[index]).data()[0]; 63 | const value = 64 | scaleType === 'ordinal' ? item.key : aggregateFormatParse(item.key); 65 | const offset = x(value); 66 | const currentNode = nodes[index][0]; 67 | 68 | let isLast = index === nodes.length - 1; 69 | if (!isLast && distribution === 'top-bottom') { 70 | isLast = index === nodes.length - 2 && width - offset < 60; 71 | } 72 | 73 | const scrollCheckAttribute = 74 | orientation === 'horizontal' ? 'offsetWidth' : 'offsetHeight'; 75 | 76 | const offsetCheckAttribute = 77 | orientation === 'horizontal' ? 'width' : 'height'; 78 | 79 | const offsetCheck = getAttribute(currentNode, offsetCheckAttribute); 80 | 81 | const domElement = dom.selectAll(nodes[index]); 82 | 83 | let backwards = isLast 84 | ? true 85 | : getParentElement(domElement).classed(cssLastClass); 86 | 87 | const offsetAttribute = 88 | orientation === 'horizontal' ? 'offsetHeight' : 'offsetWidth'; 89 | 90 | const paddingAbove = 91 | orientation === 'horizontal' ? 'padding-bottom' : 'padding-right'; 92 | 93 | const paddingBelow = 94 | orientation === 'horizontal' ? 'padding-top' : 'padding-left'; 95 | 96 | const padding = isAbove(index, distribution) 97 | ? paddingAbove 98 | : paddingBelow; 99 | 100 | const overflow = backwards 101 | ? offset - offsetCheck < 0 102 | : offset + offsetCheck > width; 103 | 104 | // Because on a resize a previous optimization could already have 105 | // repositioned items, we reset them on the first optimizer run 106 | if (optimizerRuns === 0) { 107 | backwards = isLast ? true : overflow; 108 | domElement.style(padding, '0px'); 109 | getParentElement(domElement).classed(cssLastClass, backwards); 110 | } 111 | 112 | if ( 113 | currentNode[scrollCheckAttribute] > offsetCheck || 114 | offsetCheck < offsetComparator || 115 | backwards || 116 | overflow 117 | ) { 118 | let availableWidth = null; 119 | let runs = 0; 120 | let nextCheckIterator = 121 | orientation === 'horizontal' ? nextCheck - 1 : nextCheck + 1; 122 | 123 | do { 124 | if (orientation === 'horizontal') { 125 | nextCheckIterator++; 126 | } else { 127 | nextCheckIterator--; 128 | } 129 | 130 | runs++; 131 | 132 | if (nextCheckIterator > 0) { 133 | const nextGroupHeight = getNextGroupHeight( 134 | index, 135 | nextCheck, 136 | nodes, 137 | offsetAttribute, 138 | orientation 139 | ); 140 | 141 | const previousGroupHeight = 142 | orientation === 'horizontal' 143 | ? getNextGroupHeight( 144 | index, 145 | nextCheck * -1, 146 | nodes, 147 | offsetAttribute, 148 | orientation 149 | ) 150 | : nextGroupHeight; 151 | 152 | let useNext = nextGroupHeight <= previousGroupHeight && !isLast; 153 | 154 | if (!useNext && !isLast) { 155 | useNext = offset < offsetComparator; 156 | } 157 | 158 | let groupHeight = useNext ? nextGroupHeight : previousGroupHeight; 159 | if (isLast) { 160 | groupHeight = 0; 161 | } 162 | const check = useNext ? nextCheck : nextCheck * -1; 163 | 164 | domElement.style(padding, groupHeight + 'px'); 165 | 166 | getParentElement(domElement).classed(cssLastClass, !useNext); 167 | 168 | availableWidth = getAvailableWidth( 169 | aggregateFormatParse, 170 | currentNode, 171 | index, 172 | mapping, 173 | nestedData, 174 | d, 175 | check, 176 | groupHeight, 177 | offset, 178 | offsetCheckAttribute, 179 | offsetAttribute, 180 | orientation, 181 | textMerge, 182 | width, 183 | x, 184 | useNext, 185 | scaleType // Pass scale type to getAvailableWidth 186 | ); 187 | } 188 | } while ( 189 | availableWidth < currentNode[scrollCheckAttribute] && 190 | runs < MAX_OPTIMIZER_RUNS 191 | ); 192 | 193 | if (orientation === 'horizontal') { 194 | availableWidth = Math.min(labelMaxWidth, availableWidth); 195 | } 196 | 197 | // because labels could be left or right aligned, 198 | // we shrink the available width to the inner text width 199 | // so labels facing each other will require less space. 200 | domElement.style(widthAttribute, availableWidth + 'px'); 201 | const innerWidth = getIntValueFromPxAttribute( 202 | domElement.select('.wrapper'), 203 | 'width' 204 | ); 205 | if (innerWidth < availableWidth) { 206 | availableWidth = innerWidth + 6; 207 | domElement.style(widthAttribute, availableWidth + 'px'); 208 | } 209 | 210 | if (optimizerRuns > 0 && orientation === 'horizontal') { 211 | const itemWidth = getIntValueFromPxAttribute(domElement, 'width'); 212 | const checkOffset = backwards 213 | ? offset - itemWidth 214 | : offset + itemWidth; 215 | 216 | nodes.forEach((overlapCheckNode, overlapCheckIndex) => { 217 | const overlapCheckItem = dom 218 | .selectAll(overlapCheckNode) 219 | .data()[0]; 220 | 221 | if ( 222 | overlapCheckItem.key === item.key || 223 | isSameDistribution(index, nextCheck, overlapCheckIndex) 224 | ) { 225 | return; 226 | } 227 | 228 | const overlapValue = 229 | scaleType === 'ordinal' 230 | ? overlapCheckItem.key 231 | : aggregateFormatParse(overlapCheckItem.key); 232 | let overlapCheckOffset = x(overlapValue) - 5; 233 | const overlapItemOffsetAnchor = overlapCheckOffset; 234 | const overlapCheckDomElement = dom.selectAll( 235 | nodes[overlapCheckIndex] 236 | ); 237 | const overlapCheckBackwards = getParentElement( 238 | overlapCheckDomElement 239 | ).classed(cssLastClass); 240 | 241 | if (backwards && !overlapCheckBackwards) { 242 | const overlapCheckItemWidth = getIntValueFromPxAttribute( 243 | overlapCheckDomElement, 244 | 'width' 245 | ); 246 | overlapCheckOffset = 247 | overlapCheckOffset + overlapCheckItemWidth + 5; 248 | } 249 | 250 | if (!backwards && overlapCheckBackwards) { 251 | const overlapCheckItemWidth = getIntValueFromPxAttribute( 252 | overlapCheckDomElement, 253 | 'width' 254 | ); 255 | overlapCheckOffset = 256 | overlapCheckOffset - overlapCheckItemWidth - 5; 257 | } 258 | 259 | const overlapCheck1 = backwards 260 | ? overlapCheckOffset > checkOffset 261 | : checkOffset > overlapItemOffsetAnchor; 262 | 263 | const overlapCheck2 = backwards 264 | ? overlapItemOffsetAnchor < offset 265 | : overlapItemOffsetAnchor > offset; 266 | 267 | if (overlapCheck1 && overlapCheck2) { 268 | const overlapCheckHeight = overlapCheckNode[0][offsetAttribute]; 269 | const itemPadding = getIntValueFromPxAttribute( 270 | domElement, 271 | padding 272 | ); 273 | 274 | if (itemPadding < overlapCheckHeight) { 275 | // offsetComparator 276 | // find out if there's enough place to get rid of overlap 277 | // by adjusted the items width 278 | const checkWidth = backwards 279 | ? overlapCheckOffset - checkOffset 280 | : checkOffset - overlapItemOffsetAnchor; 281 | const currentWidth = getIntValueFromPxAttribute( 282 | domElement, 283 | widthAttribute 284 | ); 285 | const reducedWidth = currentWidth - checkWidth - 6; 286 | 287 | if (reducedWidth > offsetComparator) { 288 | availableWidth = Math.min(availableWidth, reducedWidth); 289 | domElement.style(widthAttribute, `${availableWidth}px`); 290 | } else { 291 | domElement.style(padding, `${overlapCheckHeight + 5}px`); 292 | } 293 | updated++; 294 | } 295 | } 296 | }); 297 | 298 | // The optimizer might push all labels too far up. If all labels 299 | // have a minimum padding of more than 0, we'll shrink all offsets 300 | // back so the label with the smallest padding ends up directly 301 | // at the timeline. 302 | 303 | let minPadding = Number.POSITIVE_INFINITY; 304 | nodes.forEach((overlapCheckNode, overlapCheckIndex) => { 305 | const checkSameOrientation = isAbove( 306 | overlapCheckIndex, 307 | distribution 308 | ) 309 | ? paddingAbove 310 | : paddingBelow; 311 | 312 | if (checkSameOrientation !== padding) { 313 | return; 314 | } 315 | 316 | const overlapCheckDomElement = dom.selectAll( 317 | nodes[overlapCheckIndex] 318 | ); 319 | 320 | const itemPadding = getIntValueFromPxAttribute( 321 | overlapCheckDomElement, 322 | padding 323 | ); 324 | minPadding = Math.min(minPadding, itemPadding); 325 | }); 326 | 327 | if (minPadding > 0) { 328 | nodes.forEach((overlapCheckNode, overlapCheckIndex) => { 329 | const itemRowCheck = index % nextCheck; 330 | const distributionCheck = 331 | (overlapCheckIndex + itemRowCheck) % nextCheck; 332 | 333 | if (distributionCheck !== 0) { 334 | return; 335 | } 336 | 337 | const overlapCheckDomElement = dom.selectAll( 338 | nodes[overlapCheckIndex] 339 | ); 340 | const itemPadding = getIntValueFromPxAttribute( 341 | overlapCheckDomElement, 342 | padding 343 | ); 344 | overlapCheckDomElement.style( 345 | padding, 346 | `${itemPadding - minPadding}px` 347 | ); 348 | }); 349 | } 350 | } 351 | } 352 | }); 353 | }); 354 | 355 | return updated; 356 | }; 357 | 358 | let optimizerRuns = 0; 359 | let updated = 0; 360 | 361 | do { 362 | updated = runOptimizer(optimizerRuns); 363 | optimizerRuns++; 364 | 365 | // make sure we run a second optimizer call 366 | if (optimizerRuns === 1) { 367 | updated = 1; 368 | } 369 | } while (optimizerRuns < MAX_OPTIMIZER_RUNS && updated > 0); 370 | }; 371 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import * as dom from 'd3-selection'; 2 | import * as scale from 'd3-scale'; 3 | import { extent, max } from 'd3-array'; 4 | import { isoParse } from 'd3-time-format'; 5 | 6 | import { aggregateFormats } from './_aggregate_formats'; 7 | import api from './_api'; 8 | import { 9 | cssPrefix, 10 | cssCategoryClass, 11 | cssHorizontalLineClass, 12 | cssVerticalLineClass, 13 | cssGroupClass, 14 | cssBulletClass, 15 | cssLabelClass, 16 | cssAboveClass, 17 | cssTextClass, 18 | cssTitleClass, 19 | cssEventClass, 20 | cssEventHoverClass, 21 | } from './_css'; 22 | import { DEFAULTS } from './_defaults'; 23 | import { isAbove } from './_is_above'; 24 | import { optimize as optimizeFn } from './_optimize'; 25 | import { timeFormat } from './_time_format'; 26 | import { timeParse } from './_time_parse'; 27 | import { transform } from './_transform'; 28 | 29 | export default function milestones(selector) { 30 | let distribution = DEFAULTS.DISTRIBUTION; 31 | function setDistribution(d) { 32 | distribution = d; 33 | } 34 | 35 | let optimizeLayout = DEFAULTS.OPTIMIZE; 36 | function setOptimizeLayout(d) { 37 | optimizeLayout = d; 38 | } 39 | 40 | let orientation = DEFAULTS.ORIENTATION; 41 | function setOrientation(d) { 42 | orientation = d; 43 | // purge the DOM to avoid layout issues when switching orientation 44 | dom.select(selector).html(''); 45 | } 46 | 47 | let scaleType = DEFAULTS.SCALE_TYPE; 48 | function setScaleType(d) { 49 | if (d === 'time' || d === 'ordinal') { 50 | scaleType = d; 51 | // purge the DOM to avoid layout issues when switching scale type 52 | dom.select(selector).html(''); 53 | } 54 | } 55 | 56 | let parseTime = isoParse; 57 | function setParseTime(d) { 58 | parseTime = timeParse(d); 59 | } 60 | 61 | let mapping = Object.assign({}, DEFAULTS.MAPPING); 62 | function assignMapping(d) { 63 | mapping = Object.assign({}, mapping, d); 64 | } 65 | 66 | let labelFormat; 67 | function setLabelFormat(d) { 68 | labelFormat = timeFormat(d); 69 | } 70 | setLabelFormat(DEFAULTS.LABEL_FORMAT); 71 | 72 | let range; 73 | function setRange(d) { 74 | if (Array.isArray(d) && d.length == 2) { 75 | range = d; 76 | } 77 | } 78 | 79 | let useLabels; 80 | function setUseLabels(d) { 81 | useLabels = d; 82 | } 83 | setUseLabels(DEFAULTS.USE_LABELS); 84 | 85 | let urlTarget; 86 | function setUrlTarget(d) { 87 | if ( 88 | typeof d === 'string' && 89 | ['_blank', '_self', '_parent', '_top'].includes(d.toLowerCase()) 90 | ) { 91 | urlTarget = d; 92 | } 93 | } 94 | setUrlTarget(DEFAULTS.URL_TARGET); 95 | 96 | // set callback for event mouseover 97 | let callBackMouseOver; 98 | function setEventMouseOverCallback(callback) { 99 | callBackMouseOver = callback; 100 | } 101 | function eventMouseOver(d) { 102 | if (typeof callBackMouseOver === 'function') { 103 | dom.select(this).classed(cssEventHoverClass, true); 104 | callBackMouseOver(d); 105 | } 106 | return d; 107 | } 108 | 109 | // set callback for event mouseleave 110 | let callBackMouseLeave; 111 | function setEventMouseLeaveCallback(callback) { 112 | callBackMouseLeave = callback; 113 | } 114 | function eventMouseLeave(d) { 115 | if (typeof callBackMouseOver === 'function') { 116 | dom.select(this).classed(cssEventHoverClass, false); 117 | callBackMouseLeave(d); 118 | } 119 | return d; 120 | } 121 | 122 | // set callback for event click 123 | let callbackClick; 124 | function setEventClickCallback(callback) { 125 | callbackClick = callback; 126 | } 127 | function eventClick(d) { 128 | if (typeof callbackClick === 'function') { 129 | callbackClick(d); 130 | } 131 | return d; 132 | } 133 | 134 | // set callback for post-render operations 135 | let callbackRender; 136 | function renderCallback(callback) { 137 | callbackRender = callback; 138 | } 139 | 140 | let aggregateFormat = timeFormat(aggregateFormats[DEFAULTS.AGGREGATE_BY]); 141 | let aggregateFormatParse = timeParse(aggregateFormats[DEFAULTS.AGGREGATE_BY]); 142 | 143 | function setAggregateBy(d) { 144 | aggregateFormat = timeFormat(aggregateFormats[d]); 145 | aggregateFormatParse = timeParse(aggregateFormats[d]); 146 | setLabelFormat(aggregateFormats[d]); 147 | } 148 | 149 | const autoResize = { current: DEFAULTS.AUTO_RESIZE }; 150 | 151 | const resizeHandler = () => { 152 | if (dom.select(selector).node() !== null) { 153 | window.requestAnimationFrame(() => { 154 | if (autoResize.current) { 155 | render(); // Render without data parameter to re-render existing data 156 | } 157 | }); 158 | } 159 | }; 160 | 161 | const resizeObserver = new ResizeObserver(resizeHandler); 162 | resizeObserver.observe( 163 | typeof selector === 'string' ? document.querySelector(selector) : selector 164 | ); 165 | 166 | function setAutoResize(d) { 167 | autoResize.current = d; 168 | } 169 | 170 | function render(data) { 171 | // Simple render method with a single data parameter 172 | 173 | const widthAttribute = orientation === 'horizontal' ? 'width' : 'height'; 174 | const marginTimeAttribute = 175 | orientation === 'horizontal' ? 'margin-left' : 'margin-top'; 176 | const cssLineClass = 177 | orientation === 'horizontal' 178 | ? cssHorizontalLineClass 179 | : cssVerticalLineClass; 180 | const labelMaxWidth = orientation === 'horizontal' ? 180 : 100; 181 | 182 | const timelineSelection = dom.select(selector).selectAll('.' + cssPrefix); 183 | const nestedData = 184 | typeof data !== 'undefined' 185 | ? transform(aggregateFormat, data, mapping, parseTime, scaleType) 186 | : timelineSelection.data(); 187 | const timeline = timelineSelection.data(nestedData); 188 | 189 | const timelineEnter = timeline 190 | .enter() 191 | .append('div') 192 | .attr('class', cssPrefix); 193 | 194 | timeline.exit().remove(); 195 | 196 | // rightMargin compensates for the right most bullet position 197 | const rightMargin = 11; 198 | const selectorWidth = 199 | parseFloat(dom.select(selector).style(widthAttribute)) - rightMargin; 200 | 201 | if (typeof mapping.category !== 'undefined') { 202 | timelineEnter 203 | .append('div') 204 | .attr('class', cssCategoryClass) 205 | .text((d) => d.category); 206 | 207 | timelineEnter 208 | .append('div') 209 | .attr('class', 'data-js-timeline') 210 | .append('div') 211 | .attr('class', cssLineClass); 212 | } else { 213 | timelineEnter.append('div').attr('class', cssLineClass); 214 | } 215 | const timelineMerge = timeline.merge(timelineEnter); 216 | 217 | const categoryLabels = timelineMerge.selectAll('.' + cssCategoryClass); 218 | // Apply categoryStyle first before calculating widths 219 | categoryLabels.each((d, i, node) => { 220 | const categoryData = d.originalData || d; 221 | if (categoryData[mapping.categoryStyle]) { 222 | Object.entries(categoryData[mapping.categoryStyle]).forEach( 223 | ([prop, val]) => { 224 | dom.select(node[i]).style(prop, val); 225 | } 226 | ); 227 | } 228 | }); 229 | 230 | // Now calculate widths after styles are applied 231 | const categoryLabelWidths = []; 232 | categoryLabels.each((d, i, node) => { 233 | categoryLabelWidths.push(node[i].offsetWidth); 234 | }); 235 | const maxCategoryLabelWidth = Math.round(max(categoryLabelWidths) || 0); 236 | const timelineLeftMargin = 10; 237 | const width = selectorWidth - maxCategoryLabelWidth - timelineLeftMargin; 238 | categoryLabels.style('width', maxCategoryLabelWidth + 'px'); 239 | if (orientation === 'vertical') { 240 | categoryLabels.style('margin-left', '-50%'); 241 | categoryLabels.style('text-align', 'center'); 242 | } 243 | timelineMerge 244 | .selectAll('.data-js-timeline') 245 | .style( 246 | marginTimeAttribute, 247 | maxCategoryLabelWidth + timelineLeftMargin + 'px' 248 | ); 249 | timelineMerge 250 | .selectAll('.' + cssLineClass) 251 | .style(widthAttribute, width + 'px'); 252 | 253 | const groupSelector = 254 | typeof mapping.category === 'undefined' 255 | ? timelineMerge 256 | : timelineMerge.selectAll('.data-js-timeline'); 257 | const groupSelection = groupSelector.selectAll('.' + cssGroupClass); 258 | 259 | const group = groupSelection.data((d) => { 260 | return typeof mapping.category === 'undefined' ? d : d.entries; 261 | }); 262 | 263 | const allKeys = nestedData.reduce((keys, timeline) => { 264 | const t = 265 | typeof mapping.category === 'undefined' ? timeline : timeline.entries; 266 | t.map((d) => keys.push(d.key)); 267 | return keys; 268 | }, []); 269 | 270 | const domain = 271 | typeof range !== 'undefined' 272 | ? range.map(aggregateFormatParse) 273 | : extent(allKeys, (d) => aggregateFormatParse(d)); 274 | 275 | // Create the appropriate scale based on scaleType 276 | const x = 277 | scaleType === 'ordinal' 278 | ? scale.scalePoint().range([0, width]).domain(allKeys) // Keep original order for ordinal scales 279 | : scale 280 | .scaleTime() 281 | .rangeRound([0, width]) 282 | // sets oldest and newest date as the scales domain 283 | .domain(domain); 284 | 285 | const groupEnter = group.enter().append('div').attr('class', cssGroupClass); 286 | 287 | group.exit().remove(); 288 | 289 | groupEnter.append('div').attr('class', cssBulletClass); 290 | 291 | const groupMerge = groupEnter 292 | .merge(group) 293 | .style(marginTimeAttribute, (d) => { 294 | // For ordinal scale, use the key directly; for time scale, parse it 295 | d.scaleType = scaleType; // Ensure scale type is passed to data 296 | const value = 297 | scaleType === 'ordinal' ? d.key : aggregateFormatParse(d.key); 298 | return x(value) + 'px'; 299 | }); 300 | 301 | // Apply bulletStyle to bullets and calculate bullet radius (including border) 302 | const bulletRadii = new Map(); 303 | groupMerge.selectAll('.' + cssBulletClass).each(function (d, i, nodes) { 304 | const bulletStyle = d.values.reduce((p, c) => { 305 | if (c[mapping.bulletStyle] !== undefined) { 306 | return Object.assign(p, c[mapping.bulletStyle]); 307 | } 308 | return p; 309 | }, {}); 310 | 311 | Object.entries(bulletStyle).forEach(([prop, val]) => { 312 | dom.select(this).style(prop, val); 313 | }); 314 | 315 | // Calculate bullet radius after styles are applied (height includes padding + border) 316 | const bulletElement = nodes[i]; 317 | const bulletHeight = bulletElement.offsetHeight; 318 | const bulletRadius = bulletHeight / 2; 319 | bulletRadii.set(d.key, bulletRadius); 320 | }); 321 | 322 | if (useLabels) { 323 | const label = groupMerge 324 | .selectAll('.' + cssLabelClass + '-' + orientation) 325 | .data((d) => [d]); 326 | 327 | const labelMerge = label 328 | .enter() 329 | .append('div') 330 | .attr('class', cssLabelClass + '-' + orientation) 331 | .merge(label) 332 | // .classed(cssLastClass, (d) => { 333 | // const mostRightPosition = Math.round(x.range()[1]); 334 | // const currentPosition = x(aggregateFormatParse(d.key)); 335 | // return ( 336 | // mostRightPosition === currentPosition && 337 | // orientation === 'horizontal' 338 | // ); 339 | // }) 340 | .classed(cssAboveClass + '-' + orientation, (d) => 341 | isAbove(d.index, distribution) 342 | ) 343 | .each(function (d) { 344 | // Adjust label vertical position to align with bullet edge 345 | if (orientation === 'horizontal') { 346 | const bulletRadius = bulletRadii.get(d.key) || 5.5; // Default bullet radius (11px diameter / 2) 347 | const timelineCenter = 5.5; // margin-top (4px) + half line height (1.5px) 348 | const above = isAbove(d.index, distribution); 349 | 350 | if (above) { 351 | // For above labels, position at top edge of bullet 352 | const topEdge = timelineCenter - bulletRadius; 353 | dom.select(this).style('bottom', `calc(100% - ${topEdge}px)`); 354 | } else { 355 | // For below labels, position at bottom edge of bullet 356 | const bottomEdge = timelineCenter + bulletRadius; 357 | dom.select(this).style('top', bottomEdge + 'px'); 358 | } 359 | } 360 | }); 361 | 362 | const text = labelMerge 363 | .selectAll('.' + cssTextClass + '-' + orientation) 364 | .data((d) => [d]); 365 | 366 | const textEnter = text 367 | .enter() 368 | .append('div') 369 | .attr('class', cssTextClass + '-' + orientation) 370 | .merge(text) 371 | .style(widthAttribute, (d) => { 372 | // calculate the available width 373 | d.scaleType = scaleType; // Ensure scale type is passed to data 374 | const value = 375 | scaleType === 'ordinal' ? d.key : aggregateFormatParse(d.key); 376 | const offset = x(value); 377 | // get the next and previous item on the same lane 378 | let nextItem; 379 | let previousItem; 380 | let itemNumTotal; 381 | const itemNum = d.index + 1; 382 | const nextCheck = distribution === 'top-bottom' ? 2 : 1; 383 | if (typeof mapping.category === 'undefined') { 384 | nextItem = nestedData[d.timelineIndex][d.index + nextCheck]; 385 | previousItem = nestedData[d.timelineIndex][d.index - nextCheck]; 386 | itemNumTotal = nestedData[d.timelineIndex].length; 387 | } else { 388 | nextItem = nestedData[d.timelineIndex].entries[d.index + nextCheck]; 389 | previousItem = 390 | nestedData[d.timelineIndex].entries[d.index - nextCheck]; 391 | itemNumTotal = nestedData[d.timelineIndex].entries.length; 392 | } 393 | 394 | let availableWidth; 395 | const compareItem1 = 396 | orientation === 'horizontal' ? nextItem : previousItem; 397 | const compareItem2 = 398 | orientation === 'horizontal' ? previousItem : nextItem; 399 | 400 | if (typeof compareItem1 !== 'undefined') { 401 | // Pass scale type to next item 402 | compareItem1.scaleType = scaleType; 403 | const nextValue = 404 | scaleType === 'ordinal' 405 | ? compareItem1.key 406 | : aggregateFormatParse(compareItem1.key); 407 | const offsetNextItem = x(nextValue); 408 | availableWidth = 409 | orientation === 'horizontal' 410 | ? offsetNextItem - offset 411 | : offset - offsetNextItem; 412 | 413 | if (itemNumTotal - itemNum === 2) { 414 | availableWidth /= 2; 415 | } 416 | } else { 417 | if (itemNumTotal - itemNum === 1) { 418 | availableWidth = 419 | orientation === 'horizontal' ? width - offset : offset; 420 | } else if (itemNumTotal - itemNum === 0) { 421 | if (typeof compareItem2 !== 'undefined') { 422 | // Pass scale type to previous item 423 | compareItem2.scaleType = scaleType; 424 | const prevValue = 425 | scaleType === 'ordinal' 426 | ? compareItem2.key 427 | : aggregateFormatParse(compareItem2.key); 428 | const offsetPreviousItem = x(prevValue); 429 | availableWidth = 430 | orientation === 'horizontal' 431 | ? (width - offsetPreviousItem) / 2 432 | : offsetPreviousItem / 2; 433 | } else { 434 | availableWidth = width; 435 | } 436 | } 437 | } 438 | 439 | const labelRightMargin = 6; 440 | const availableWidthWithMargin = Math.max( 441 | 0, 442 | availableWidth - labelRightMargin 443 | ); 444 | const finalWidth = Math.min( 445 | orientation === 'horizontal' 446 | ? labelMaxWidth 447 | : availableWidthWithMargin, 448 | availableWidthWithMargin 449 | ); 450 | return finalWidth + 'px'; 451 | }) 452 | .each(function (d) { 453 | const above = isAbove(d.index, distribution); 454 | 455 | const wrapper = dom.select(this); 456 | wrapper.html(null); 457 | 458 | // Aggregate titleStyle from all items in group 459 | const titleStyle = d.values.reduce((p, c) => { 460 | if (c[mapping.titleStyle] !== undefined) { 461 | return Object.assign(p, c[mapping.titleStyle]); 462 | } 463 | return p; 464 | }, {}); 465 | 466 | const element = wrapper.append('div').classed('wrapper', true); 467 | 468 | // Render title once per group (before items if not above or vertical) 469 | if (!above || orientation === 'vertical') { 470 | const titleSpan = element 471 | .append('span') 472 | .classed(cssTitleClass, true); 473 | 474 | // Format label based on scale type 475 | if (scaleType === 'ordinal') { 476 | titleSpan.text(d.key); 477 | } else { 478 | titleSpan.text(labelFormat(aggregateFormatParse(d.key))); 479 | } 480 | 481 | Object.entries(titleStyle).forEach(([prop, val]) => 482 | titleSpan.style(prop, val) 483 | ); 484 | 485 | element.append('br'); 486 | } 487 | 488 | d.values.map((v, i) => { 489 | if (i > 0) { 490 | element.append('br'); 491 | } 492 | 493 | const textStyle = Object.assign({}, v[mapping.textStyle]); 494 | 495 | const t = v[mapping.text]; 496 | let item; 497 | // test if text is an image filename, 498 | // if so return an image tag with the filename as the source 499 | if ( 500 | ['jpg', 'jpeg', 'gif', 'png', 'webp'].indexOf( 501 | t.split('.').pop() 502 | ) > -1 503 | ) { 504 | item = element 505 | .append('img') 506 | .classed('milestones-label', true) 507 | .classed('milestones-image-label', true) 508 | .attr('height', '100') 509 | .attr('src', t); 510 | } else if (v[mapping.url]) { 511 | item = element 512 | .append('a') 513 | .classed('milestones-label', true) 514 | .classed('milestones-link-label', true) 515 | .attr('href', v[mapping.url]) 516 | .attr('target', urlTarget) 517 | .text(t); 518 | } else { 519 | item = element 520 | .append('span') 521 | .classed('milestones-label', true) 522 | .classed('milestones-text-label', true) 523 | .text(t); 524 | } 525 | 526 | // Apply custom ID if provided 527 | if (v[mapping.id]) { 528 | item.attr('id', v[mapping.id]); 529 | } 530 | 531 | item.datum({ 532 | text: v[mapping.text], 533 | timestamp: v[mapping.timestamp], 534 | attributes: v, // original value of an object passed to the milestone 535 | }); 536 | 537 | if ( 538 | typeof callbackClick === 'function' || 539 | typeof callBackMouseLeave === 'function' || 540 | typeof callBackMouseOver === 'function' 541 | ) { 542 | item.classed(cssEventClass, true); 543 | } 544 | 545 | if (typeof callbackClick === 'function') { 546 | item.on('click', eventClick); 547 | } 548 | 549 | if (typeof callBackMouseLeave === 'function') { 550 | item.on('mouseleave', eventMouseLeave); 551 | } 552 | 553 | if (typeof callBackMouseOver === 'function') { 554 | item.on('mouseover', eventMouseOver); 555 | } 556 | 557 | Object.entries(textStyle).forEach(([prop, val]) => 558 | item.style(prop, val) 559 | ); 560 | }); 561 | 562 | // Render title once per group (after items if above in horizontal mode) 563 | if (above && orientation === 'horizontal') { 564 | element.append('br'); 565 | const titleSpan = element 566 | .append('span') 567 | .classed(cssTitleClass, true); 568 | 569 | // Format label based on scale type 570 | if (scaleType === 'ordinal') { 571 | titleSpan.text(d.key); 572 | } else { 573 | titleSpan.text(labelFormat(aggregateFormatParse(d.key))); 574 | } 575 | 576 | Object.entries(titleStyle).forEach(([prop, val]) => 577 | titleSpan.style(prop, val) 578 | ); 579 | } 580 | }); 581 | 582 | const textMerge = text.merge(textEnter); 583 | 584 | textMerge.style('padding-top', '0px').style('padding-bottom', '0px'); 585 | 586 | if (optimizeLayout) { 587 | optimizeFn( 588 | aggregateFormatParse, 589 | distribution, 590 | labelMaxWidth, 591 | mapping, 592 | nestedData, 593 | orientation, 594 | textMerge, 595 | width, 596 | widthAttribute, 597 | x, 598 | scaleType // Pass scale type to optimizer 599 | ); 600 | } 601 | } else { 602 | groupMerge.selectAll('.' + cssLabelClass + '-' + orientation).remove(); 603 | } 604 | 605 | // finally, adjust offset, height and width of the whole timeline 606 | timelineMerge.each((d, i, node) => { 607 | const margin = 10; 608 | const maxAboveHeight = max( 609 | dom 610 | .select(node[i]) 611 | .selectAll( 612 | '.' + 613 | cssLabelClass + 614 | '-' + 615 | orientation + 616 | '.' + 617 | cssAboveClass + 618 | '-' + 619 | orientation 620 | )._groups[0], 621 | (d) => d.offsetHeight 622 | ); 623 | const maxBelowHeight = max( 624 | dom 625 | .select(node[i]) 626 | .selectAll( 627 | '.' + 628 | cssLabelClass + 629 | '-' + 630 | orientation + 631 | ':not(.' + 632 | cssAboveClass + 633 | '-' + 634 | orientation + 635 | ')' 636 | )._groups[0], 637 | (d) => d.offsetHeight 638 | ); 639 | 640 | if (orientation === 'horizontal') { 641 | dom 642 | .select(node[i]) 643 | .style('margin-top', margin + (maxAboveHeight || 0) + 'px') 644 | .style('height', margin + (maxBelowHeight || 0) + 'px'); 645 | } else { 646 | const percent = 647 | typeof mapping.category !== 'undefined' 648 | ? Math.round(100 / (nestedData.length + 1)) * (i + 1) 649 | : '50'; 650 | dom 651 | .select(node[i]) 652 | .style('margin-top', '50px') 653 | .style('margin-left', percent + '%') 654 | .style('position', 'absolute'); 655 | } 656 | }); 657 | 658 | // Execute render callback if provided 659 | if (typeof callbackRender === 'function') { 660 | callbackRender(); 661 | } 662 | } 663 | 664 | return api({ 665 | aggregateBy: setAggregateBy, 666 | mapping: assignMapping, 667 | optimize: setOptimizeLayout, 668 | autoResize: setAutoResize, 669 | orientation: setOrientation, 670 | distribution: setDistribution, 671 | scaleType: setScaleType, 672 | parseTime: setParseTime, 673 | labelFormat: setLabelFormat, 674 | urlTarget: setUrlTarget, 675 | useLabels: setUseLabels, 676 | range: setRange, 677 | render: render, 678 | renderCallback: renderCallback, 679 | onEventClick: setEventClickCallback, 680 | onEventMouseLeave: setEventMouseLeaveCallback, 681 | onEventMouseOver: setEventMouseOverCallback, 682 | }); 683 | } 684 | --------------------------------------------------------------------------------