41 | MetricsGraphics is a library built on top of D3 that is optimized for visualizing and laying out time-series
42 | data. It provides a simple way to produce common types of graphics in a principled, consistent and responsive
43 | way.
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default Home
51 |
--------------------------------------------------------------------------------
/app/pages/line.mdx:
--------------------------------------------------------------------------------
1 | import ParameterTable from '../components/ParameterTable'
2 | import Layout from '../components/Layout'
3 | import Simple from '../components/charts/line/Simple'
4 | import Confidence from '../components/charts/line/Confidence'
5 | import Multi from '../components/charts/line/Multi'
6 | import Aggregated from '../components/charts/line/Aggregated'
7 | import Broken from '../components/charts/line/Broken'
8 | import Active from '../components/charts/line/Active'
9 | import Baseline from '../components/charts/line/Baseline'
10 |
11 |
12 |
13 | # Line Charts
14 |
15 | ## API
16 |
17 | Extends the [base chart options](./mg-api). All options below are optional.
18 |
19 | | boolean',
22 | description: 'Specifies for which sub-array of data an area should be shown. If the chart is only one line, you can set it to true.'
23 | }, {
24 | name: 'confidenceBand',
25 | type: '[Accessor, Accessor]',
26 | description: 'Two-element array specifying how to access the lower (first) and upper (second) value for the confidence band. The two elements work like accessors (either a string or a function).'
27 | }, {
28 | name: 'voronoi',
29 | type: 'Partial',
30 | description: 'Custom parameters passed to the voronoi generator.'
31 | }, {
32 | name: 'defined',
33 | type: '(point: Data) => boolean',
34 | description: 'Function specifying whether or not to show a given datapoint. This is mainly used to create partially defined graphs.'
35 | }, {
36 | name: 'activeAccessor',
37 | type: 'Accessor',
38 | description: 'Accessor that defines whether or not a given data point should be shown as active'
39 | }, {
40 | name: 'activePoint',
41 | type: 'Partial',
42 | description: 'Custom parameters passed to the active point generator.'
43 | }]} />
44 |
45 | ## Examples
46 |
47 | ### Simple Line Chart
48 |
49 | This is a simple line chart. You can remove the area portion by adding `area: false` to the arguments list.
50 |
51 |
52 |
53 | ```js
54 | new LineChart({
55 | data: [fakeUsers.map(({ date, value }) => ({ date: new Date(date), value }))],
56 | width: 600,
57 | height: 200,
58 | yScale: {
59 | minValue: 0
60 | },
61 | target: '#my-div',
62 | brush: 'xy',
63 | area: true,
64 | xAccessor: 'date',
65 | yAccessor: 'value',
66 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
67 | })
68 | ```
69 |
70 |
71 |
72 | ### Confidence Band
73 |
74 | This is an example of a graph with a confidence band and extended x-axis ticks enabled.
75 |
76 |
77 |
78 | ```js
79 | new LineChart({
80 | data: [
81 | confidence.map((entry) => ({
82 | ...entry,
83 | date: new Date(entry.date)
84 | }))
85 | ],
86 | xAxis: {
87 | extendedTicks: true
88 | },
89 | yAxis: {
90 | tickFormat: 'percentage'
91 | },
92 | width: 600,
93 | height: 200,
94 | target: '#my-div',
95 | confidenceBand: ['l', 'u'],
96 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatPercent(point.value)}`
97 | })
98 | ```
99 |
100 |
101 | ### Multiple Lines
102 |
103 | This line chart contains multiple lines.
104 |
105 |
106 | ```js
107 | new LineChart({
108 | data: fakeUsers.map((fakeArray) =>
109 | fakeArray.map((fakeEntry) => ({
110 | ...fakeEntry,
111 | date: new Date(fakeEntry.date)
112 | }))
113 | ),
114 | width: 600,
115 | height: 200,
116 | target: '#my-div',
117 | xAccessor: 'date',
118 | yAccessor: 'value',
119 | legend: ['Line 1', 'Line 2', 'Line 3'],
120 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
121 | })
122 | ```
123 |
124 |
125 | ### Aggregate Rollover
126 |
127 | One rollover for all lines.
128 |
129 |
130 |
131 | ```js
132 | new LineChart({
133 | data: fakeUsers.map((fakeArray) =>
134 | fakeArray.map((fakeEntry) => ({
135 | ...fakeEntry,
136 | date: new Date(fakeEntry.date)
137 | }))
138 | ),
139 | width: 600,
140 | height: 200,
141 | target: '#my-div',
142 | xAccessor: 'date',
143 | yAccessor: 'value',
144 | legend: ['Line 1', 'Line 2', 'Line 3'],
145 | voronoi: {
146 | aggregate: true
147 | },
148 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
149 | })
150 | ```
151 |
152 |
153 | ### Broken lines (missing data points)
154 |
155 | You can hide individual data points on a particular attribute by setting the defined accessor (which has to return true for visible points). Data points whose y-accessor values are null are also hidden.
156 |
157 |
158 |
159 | ```js
160 | new LineChart({
161 | data: [missing.map((e) => ({ ...e, date: new Date(e.date) }))],
162 | width: 600,
163 | height: 200,
164 | target: '#my-div',
165 | defined: (d) => !d.dead,
166 | area: true,
167 | tooltipFunction: (point) => `${formatDate(point.date)}: ${point.value}`
168 | })
169 | ```
170 |
171 |
172 |
173 | ### Active Points
174 |
175 | This line chart displays pre-defined active points.
176 |
177 |
178 |
179 | ```js
180 | new LineChart({
181 | data: [
182 | fakeUsers.map((entry, i) => ({
183 | ...entry,
184 | date: new Date(entry.date),
185 | active: i % 5 === 0
186 | }))
187 | ],
188 | width: 600,
189 | height: 200,
190 | target: '#my-div',
191 | activeAccessor: 'active',
192 | activePoint: {
193 | radius: 2
194 | },
195 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
196 | })
197 | ```
198 |
199 |
200 | ### Baseline
201 |
202 | Baselines are horizontal lines that can added at arbitrary points.
203 |
204 |
205 |
206 | ```js
207 | new LineChart({
208 | data: [
209 | fakeUsers.map((entry) => ({
210 | ...entry,
211 | date: new Date(entry.date)
212 | }))
213 | ],
214 | baselines: [{ value: 160000000, label: 'a baseline' }],
215 | width: 600,
216 | height: 200,
217 | target: '#my-div',
218 | tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
219 | })
220 | ```
221 |
222 |
223 |
224 |
--------------------------------------------------------------------------------
/app/pages/mg-api.mdx:
--------------------------------------------------------------------------------
1 | import ParameterTable from '../components/ParameterTable'
2 | import Layout from '../components/Layout'
3 |
4 |
5 |
6 | # API
7 |
8 | All MetricsGraphics charts are classes that can be instantiated with a set of parameters (e.g. `new LineChart({ ... })`). The chart is then mounted to the given `target` (see below), which is for example the `id` of an empty `div` in your DOM or a React `ref`.
9 |
10 | ## Data formats
11 |
12 | MetricsGraphics assumes that your data is either an array of objects or an array of arrays of objects. For example, your data could look like this:
13 |
14 | ```js
15 | [{
16 | date: '2020-02-01',
17 | value: 10
18 | }, {
19 | date: '2020-02-02',
20 | value: 12
21 | }]
22 | ```
23 |
24 | ## Common Parameters
25 |
26 | All charts inherit from an abstract chart, which has the following parameters (optional parameters marked with `?`):
27 |
28 | ',
31 | description: 'Data that is to be visualized.'
32 | }, {
33 | name: 'target',
34 | type: 'string',
35 | description: 'DOM node to which the graph will be mounted (compatible D3 selection or D3 selection specifier).'
36 | }, {
37 | name: 'width',
38 | type: 'number',
39 | description: 'Total width of the graph.'
40 | }, {
41 | name: 'height',
42 | type: 'number',
43 | description: 'Total height of the graph.'
44 | }, {
45 | name: 'markers?',
46 | type: 'Array',
47 | description: 'Markers that should be added to the chart. Each marker object should be accessible through the xAccessor and contain a label field.'
48 | }, {
49 | name: 'baselines?',
50 | type: 'Array',
51 | description: 'Baselines that should be added to the chart. Each baseline object should be accessible through the yAccessor and contain a label field.'
52 | }, {
53 | name: 'xAccessor?',
54 | type: 'string | Accessor',
55 | default: 'date',
56 | description: 'Either the name of the field that contains the x value or a function that receives a data object and returns its x value.'
57 | }, {
58 | name: 'yAccessor?',
59 | type: 'string | Accessor',
60 | default: 'value',
61 | description: 'Either the name of the field that contains the y value or a function that receives a data object and returns its y value.'
62 | }, {
63 | name: 'margin?',
64 | type: 'Margin',
65 | default: 'top: 10, left: 60, right: 20, bottom: 40',
66 | description: 'Margin around the chart for labels.'
67 | }, {
68 | name: 'buffer?',
69 | type: 'number',
70 | default: '10',
71 | description: 'Amount of buffer space between the axes and the actual graph.'
72 | }, {
73 | name: 'colors?',
74 | type: 'Array',
75 | default: 'd3.schemeCategory10',
76 | description: 'Custom color scheme for the graph.'
77 | }, {
78 | name: 'xScale?',
79 | type: 'Partial',
80 | description: 'Overwrite parameters of the auto-generated x scale.'
81 | }, {
82 | name: 'yScale?',
83 | type: 'Partial',
84 | description: 'Overwrite parameters of the auto-generated y scale.'
85 | }, {
86 | name: 'xAxis?',
87 | type: 'Partial',
88 | description: 'Overwrite parameters of the auto-generated x axis.'
89 | }, {
90 | name: 'yAxis?',
91 | type: 'Partial',
92 | description: 'Overwrite parameters of the auto-generated y axis.'
93 | }, {
94 | name: 'showTooltip?',
95 | type: 'boolean',
96 | default: 'true',
97 | description: 'Whether or not to show a tooltip.'
98 | }, {
99 | name: 'tooltipFunction',
100 | type: 'Accessor',
101 | description: 'Generate a custom tooltip string.'
102 | }, {
103 | name: 'legend?',
104 | type: 'Array',
105 | description: 'Used if data is an array of arrays. Names of the sub-arrays of data, used as legend labels.'
106 | }, {
107 | name: 'brush?',
108 | type: '"xy" | "x" | "y"',
109 | description: 'Adds either a one- or two-dimensional brush to the chart.'
110 | }]} />
111 |
112 | ## Common Types
113 |
114 | ```ts
115 | type Accessor = (dataObject: X) => Y
116 |
117 | type Margin = {
118 | left: number
119 | right: number
120 | bottom: number
121 | top: number
122 | }
123 |
124 | type Scale = {
125 | type: 'linear' // this will be extended in the future
126 | range?: [number, number]
127 | domain?: [number, number]
128 | }
129 |
130 | type Axis = {
131 | scale: Scale
132 | buffer: number
133 | show?: boolean
134 | orientation?: 'top' | 'bottom' | 'left' | 'right'
135 | label?: string
136 | labelOffset?: number
137 | top?: number
138 | left?: number
139 |
140 | // a function to format a given tick, or one of the standard types (date, number, percentage), or string for d3-format
141 | tickFormat?: TextFunction | AxisFormat | string
142 |
143 | // defaults to 3 for vertical and 6 for horizontal axes
144 | tickCount?: number
145 |
146 | compact?: boolean
147 |
148 | // tick label prefix
149 | prefix?: string
150 |
151 | // tick label suffix
152 | suffix?: string
153 |
154 | // overwrite d3's default tick lengths
155 | tickLength?: number
156 |
157 | // draw extended tick lines
158 | extendedTicks?: boolean
159 | }
160 | ```
161 |
162 |
--------------------------------------------------------------------------------
/app/pages/scatter.mdx:
--------------------------------------------------------------------------------
1 | import Layout from '../components/Layout'
2 | import ParameterTable from '../components/ParameterTable'
3 | import Simple from '../components/charts/scatter/Simple'
4 | import Categories from '../components/charts/scatter/Categories'
5 | import Complex from '../components/charts/scatter/Complex'
6 |
7 |
8 |
9 | # Scatterplots
10 |
11 | ## API
12 |
13 | Extends the [base chart options](./mg-api). All options below are optional.
14 |
15 | 3'
20 | }, {
21 | name: 'xRug',
22 | type: 'boolean',
23 | description: 'Whether or not to generate a rug for the x axis.'
24 | }, {
25 | name: 'yRug',
26 | type: 'boolean',
27 | description: 'Whether or not to generate a rug for the y axis.'
28 | }]} />
29 |
30 | ## Examples
31 |
32 | ### Simple Scatterplot
33 |
34 | This is an example scatterplot, in which we have enabled rug plots on the y-axis by setting the rug option to `true`.
35 |
36 |
37 |
38 | ```js
39 | new ScatterChart({
40 | data: [points1],
41 | width: 500,
42 | height: 200,
43 | target: '#my-div',
44 | xAccessor: 'x',
45 | yAccessor: 'y',
46 | brush: 'xy',
47 | xRug: true,
48 | tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}`
49 | })
50 | ```
51 |
52 |
53 |
54 | ### Multi-Category Scatterplot
55 |
56 | This scatterplot contains data of multiple categories.
57 |
58 |
59 |
60 | ```js
61 | new ScatterChart({
62 | data: points2.map((x: any) => x.values),
63 | legend: points2.map((x: any) => x.key),
64 | width: 500,
65 | height: 200,
66 | xAccessor: 'x',
67 | yAccessor: 'y',
68 | yRug: true,
69 | target: '#my-div',
70 | tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}`
71 | })
72 | ```
73 |
74 |
75 | ### Scatterplot with Size and Color
76 |
77 | Scatterplots have xAccessor, yAccessor and sizeAccessor.
78 |
79 |
80 |
81 | ```js
82 | new ScatterChart({
83 | data: points2.map((x: any) => x.values),
84 | legend: points2.map((x: any) => x.key),
85 | width: 500,
86 | height: 200,
87 | target: '#my-div',
88 | xAccessor: 'x',
89 | yAccessor: 'y',
90 | sizeAccessor: (x: any) => Math.abs(x.w) * 3,
91 | tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}: ${formatDecimal(point.w)}`
92 | })
93 | ```
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/app/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/app/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .token.prolog,
6 | .token.doctype,
7 | .token.cdata {
8 | @apply text-gray-700;
9 | }
10 |
11 | .token.comment {
12 | @apply text-gray-500;
13 | }
14 |
15 | .token.punctuation {
16 | @apply text-gray-700;
17 | }
18 |
19 | .token.property,
20 | .token.tag,
21 | .token.boolean,
22 | .token.number,
23 | .token.constant,
24 | .token.symbol,
25 | .token.deleted {
26 | @apply text-green-500;
27 | }
28 |
29 | .token.selector,
30 | .token.attr-name,
31 | .token.string,
32 | .token.char,
33 | .token.builtin,
34 | .token.inserted {
35 | @apply text-purple-500;
36 | }
37 |
38 | .token.operator,
39 | .token.entity,
40 | .token.url,
41 | .language-css .token.string,
42 | .style .token.string {
43 | @apply text-yellow-500;
44 | }
45 |
46 | .token.atrule,
47 | .token.attr-value,
48 | .token.keyword {
49 | @apply text-blue-500;
50 | }
51 |
52 | .token.function,
53 | .token.class-name {
54 | @apply text-pink-500;
55 | }
56 |
57 | .token.regex,
58 | .token.important,
59 | .token.variable {
60 | @apply text-yellow-500;
61 | }
62 |
63 | code[class*='language-'],
64 | pre[class*='language-'] {
65 | @apply text-gray-800;
66 | }
67 |
68 | pre::-webkit-scrollbar {
69 | display: none;
70 | }
71 |
72 | pre {
73 | @apply bg-gray-50;
74 | -ms-overflow-style: none; /* IE and Edge */
75 | scrollbar-width: none; /* Firefox */
76 | }
--------------------------------------------------------------------------------
/app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const defaultTheme = require('tailwindcss/defaultTheme')
3 |
4 | module.exports = {
5 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
6 | theme: {
7 | extend: {
8 | typography: ({ theme }) => ({
9 | DEFAULT: {
10 | css: {
11 | pre: {
12 | backgroundColor: theme('colors.indigo[50]'),
13 | fontSize: '0.7rem'
14 | }
15 | }
16 | }
17 | }),
18 | fontFamily: {
19 | sans: ['Inter var', ...defaultTheme.fontFamily.sans]
20 | }
21 | }
22 | },
23 | plugins: [require('@tailwindcss/typography')]
24 | }
25 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/lib/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | bower_components
3 | node_modules
4 |
5 | # Logs
6 | npm-debug.log
7 |
8 | # IDE
9 | .idea
10 |
11 | # FS
12 | .DS_Store
13 |
14 | # Others
15 | other/divider.psd
16 | other/htaccess.txt
17 | bare.html
18 | yarn.lock
19 | yarn-error.log
20 |
21 | # Dist files
22 | dist
23 | build
24 |
--------------------------------------------------------------------------------
/lib/esbuild.mjs:
--------------------------------------------------------------------------------
1 | import esbuild from 'esbuild'
2 |
3 | const baseConfig = {
4 | entryPoints: ['src/index.ts'],
5 | bundle: true,
6 | sourcemap: true,
7 | target: 'esnext'
8 | }
9 |
10 | // esm
11 | esbuild.build({
12 | ...baseConfig,
13 | outdir: 'dist/esm',
14 | splitting: true,
15 | format: 'esm'
16 | })
17 |
18 | // cjs
19 | esbuild.build({
20 | ...baseConfig,
21 | outdir: 'dist/cjs',
22 | format: 'cjs'
23 | })
24 |
--------------------------------------------------------------------------------
/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "metrics-graphics",
3 | "version": "3.0.1",
4 | "description": "A library optimized for concise, principled data graphics and layouts",
5 | "main": "dist/cjs/index.js",
6 | "module": "dist/esm/index.js",
7 | "types": "dist/index.d.ts",
8 | "scripts": {
9 | "ts-types": "tsc --emitDeclarationOnly --outDir dist",
10 | "build": "rimraf dist && concurrently \"node ./esbuild.mjs\" \"npm run ts-types\" && cp src/mg.css dist/mg.css",
11 | "lint": "eslint src",
12 | "test": "echo \"no tests set up, will do later\"",
13 | "analyze": "source-map-explorer dist/esm/index.js"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git://github.com/metricsgraphics/metrics-graphics.git"
18 | },
19 | "files": [
20 | "dist"
21 | ],
22 | "keywords": [
23 | "metrics-graphics",
24 | "metricsgraphicsjs",
25 | "metricsgraphics",
26 | "metricsgraphics.js",
27 | "d3 charts"
28 | ],
29 | "author": "Mozilla",
30 | "contributors": [
31 | "Ali Almossawi",
32 | "Hamilton Ulmer",
33 | "William Lachance",
34 | "Jens Ochsenmeier"
35 | ],
36 | "license": "MPL-2.0",
37 | "bugs": {
38 | "url": "https://github.com/metricsgraphics/metrics-graphics/issues"
39 | },
40 | "engines": {
41 | "node": ">=0.8.0"
42 | },
43 | "homepage": "http://metricsgraphicsjs.org",
44 | "dependencies": {
45 | "d3": "^7.4.4"
46 | },
47 | "devDependencies": {
48 | "@types/d3": "^7.1.0",
49 | "concurrently": "^7.2.0",
50 | "deepmerge": "^4.2.2",
51 | "esbuild": "^0.14.39",
52 | "rimraf": "^3.0.2",
53 | "source-map-explorer": "^2.5.2"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/lib/src/charts/abstractChart.ts:
--------------------------------------------------------------------------------
1 | import { select, extent, max, brush as d3brush, brushX, brushY } from 'd3'
2 | import { randomId, makeAccessorFunction } from '../misc/utility'
3 | import Scale from '../components/scale'
4 | import Axis, { IAxis, AxisOrientation } from '../components/axis'
5 | import Tooltip from '../components/tooltip'
6 | import Legend from '../components/legend'
7 | import constants from '../misc/constants'
8 | import Point, { IPoint } from '../components/point'
9 | import {
10 | SvgD3Selection,
11 | AccessorFunction,
12 | Margin,
13 | GenericD3Selection,
14 | BrushType,
15 | DomainObject,
16 | Domain,
17 | LegendSymbol
18 | } from '../misc/typings'
19 |
20 | type TooltipFunction = (datapoint: any) => string
21 |
22 | export interface IAbstractChart {
23 | /** data that is to be visualized */
24 | data: Array
25 |
26 | /** DOM node to which the graph will be mounted (D3 selection or D3 selection specifier) */
27 | target: string
28 |
29 | /** total width of the graph */
30 | width: number
31 |
32 | /** total height of the graph */
33 | height: number
34 |
35 | /** markers that should be added to the chart. Each marker object should be accessible through the xAccessor and contain a label field */
36 | markers?: Array
37 |
38 | /** baselines that should be added to the chart. Each baseline object should be accessible through the yAccessor and contain a label field */
39 | baselines?: Array
40 |
41 | /** either name of the field that contains the x value or function that receives a data object and returns its x value */
42 | xAccessor?: string | AccessorFunction
43 |
44 | /** either name of the field that contains the y value or function that receives a data object and returns its y value */
45 | yAccessor?: string | AccessorFunction
46 |
47 | /** margins of the visualization for labels */
48 | margin?: Margin
49 |
50 | /** amount of buffer between the axes and the graph */
51 | buffer?: number
52 |
53 | /** custom color scheme for the graph */
54 | colors?: Array
55 |
56 | /** overwrite parameters of the auto-generated x scale */
57 | xScale?: Partial
58 |
59 | /** overwrite parameters of the auto-generated y scale */
60 | yScale?: Partial
61 |
62 | /** overwrite parameters of the auto-generated x axis */
63 | xAxis?: Partial
64 |
65 | /** overwrite parameters of the auto-generated y axis */
66 | yAxis?: Partial
67 |
68 | /** whether or not to show a tooltip */
69 | showTooltip?: boolean
70 |
71 | /** generate a custom tooltip string */
72 | tooltipFunction?: (datapoint: any) => string
73 |
74 | /** names of the sub-arrays of data, used as legend labels */
75 | legend?: Array
76 |
77 | /** add an optional brush */
78 | brush?: BrushType
79 |
80 | /** custom domain computations */
81 | computeDomains?: () => DomainObject
82 | }
83 |
84 | /**
85 | * This abstract chart class implements all functionality that is shared between all available chart types.
86 | */
87 | export default abstract class AbstractChart {
88 | id: string
89 |
90 | // base chart fields
91 | data: Array
92 | markers: Array
93 | baselines: Array
94 | target: SvgD3Selection
95 | svg?: GenericD3Selection
96 | content?: GenericD3Selection
97 | container?: GenericD3Selection
98 |
99 | // accessors
100 | xAccessor: AccessorFunction
101 | yAccessor: AccessorFunction
102 | colors: Array
103 |
104 | // scales
105 | xDomain: Domain
106 | yDomain: Domain
107 | xScale: Scale
108 | yScale: Scale
109 |
110 | // axes
111 | xAxis?: Axis
112 | xAxisParams: any
113 | yAxis?: Axis
114 | yAxisParams: any
115 |
116 | // tooltip and legend stuff
117 | showTooltip: boolean
118 | tooltipFunction?: TooltipFunction
119 | tooltip?: Tooltip
120 | legend?: Array
121 |
122 | // dimensions
123 | width: number
124 | height: number
125 |
126 | // margins
127 | margin: Margin
128 | buffer: number
129 |
130 | // brush
131 | brush?: BrushType
132 | idleDelay = 350
133 | idleTimeout: unknown
134 |
135 | constructor({
136 | data,
137 | target,
138 | markers,
139 | baselines,
140 | xAccessor,
141 | yAccessor,
142 | margin,
143 | buffer,
144 | width,
145 | height,
146 | colors,
147 | xScale,
148 | yScale,
149 | xAxis,
150 | yAxis,
151 | showTooltip,
152 | tooltipFunction,
153 | legend,
154 | brush,
155 | computeDomains
156 | }: IAbstractChart) {
157 | // convert string accessors to functions if necessary
158 | this.xAccessor = makeAccessorFunction(xAccessor ?? 'date')
159 | this.yAccessor = makeAccessorFunction(yAccessor ?? 'value')
160 |
161 | // set parameters
162 | this.data = data
163 | this.target = select(target)
164 | this.markers = markers ?? []
165 | this.baselines = baselines ?? []
166 | this.legend = legend ?? this.legend
167 | this.brush = brush ?? undefined
168 | this.xAxisParams = xAxis ?? this.xAxisParams
169 | this.yAxisParams = yAxis ?? this.yAxisParams
170 | this.showTooltip = showTooltip ?? true
171 | this.tooltipFunction = tooltipFunction
172 |
173 | this.margin = margin ?? { top: 10, left: 60, right: 20, bottom: 40 }
174 | this.buffer = buffer ?? 10
175 |
176 | // set unique id for chart
177 | this.id = randomId()
178 |
179 | // compute dimensions
180 | this.width = width
181 | this.height = height
182 |
183 | // normalize color and colors arguments
184 | this.colors = colors ?? constants.defaultColors
185 |
186 | // clear target
187 | this.target.selectAll('*').remove()
188 |
189 | // attach base elements to svg
190 | this.mountSvg()
191 |
192 | // set up scales
193 | this.xScale = new Scale({ range: [0, this.innerWidth], ...xScale })
194 | this.yScale = new Scale({ range: [this.innerHeight, 0], ...yScale })
195 |
196 | // compute domains and set them
197 | const { x, y } = computeDomains ? computeDomains() : this.computeDomains()
198 | this.xDomain = x
199 | this.yDomain = y
200 | this.xScale.domain = x
201 | this.yScale.domain = y
202 |
203 | this.abstractRedraw()
204 | }
205 |
206 | /**
207 | * Draw the abstract chart.
208 | */
209 | abstractRedraw(): void {
210 | // if not drawn yet, abort
211 | if (!this.content) return
212 |
213 | // clear
214 | this.content.selectAll('*').remove()
215 |
216 | // set up axes if not disabled
217 | this.mountXAxis(this.xAxisParams)
218 | this.mountYAxis(this.yAxisParams)
219 |
220 | // pre-attach tooltip text container
221 | this.mountTooltip(this.showTooltip, this.tooltipFunction)
222 |
223 | // set up main container
224 | this.mountContainer()
225 | }
226 |
227 | /**
228 | * Draw the actual chart.
229 | * This is meant to be overridden by chart implementations.
230 | */
231 | abstract redraw(): void
232 |
233 | mountBrush(whichBrush?: BrushType): void {
234 | // if no brush is specified, there's nothing to mount
235 | if (!whichBrush) return
236 |
237 | // brush can only be mounted after content is set
238 | if (!this.content || !this.container) {
239 | console.error('error: content not set yet')
240 | return
241 | }
242 |
243 | const brush = whichBrush === 'x' ? brushX() : whichBrush === 'y' ? brushY() : d3brush()
244 | brush.on('end', ({ selection }) => {
245 | // if no content is set, do nothing
246 | if (!this.content) {
247 | console.error('error: content is not set yet')
248 | return
249 | }
250 | // compute domains and re-draw
251 | if (selection === null) {
252 | if (!this.idleTimeout) {
253 | this.idleTimeout = setTimeout(() => {
254 | this.idleTimeout = null
255 | }, 350)
256 | return
257 | }
258 |
259 | // set original domains
260 | this.xScale.domain = this.xDomain
261 | this.yScale.domain = this.yDomain
262 | } else {
263 | if (this.brush === 'x') {
264 | this.xScale.domain = [selection[0], selection[1]].map(this.xScale.scaleObject.invert)
265 | } else if (this.brush === 'y') {
266 | this.yScale.domain = [selection[0], selection[1]].map(this.yScale.scaleObject.invert)
267 | } else {
268 | this.xScale.domain = [selection[0][0], selection[1][0]].map(this.xScale.scaleObject.invert)
269 | this.yScale.domain = [selection[1][1], selection[0][1]].map(this.yScale.scaleObject.invert)
270 | }
271 | this.content.select('.brush').call((brush as any).move, null)
272 | }
273 |
274 | // re-draw abstract elements
275 | this.abstractRedraw()
276 |
277 | // re-draw specific chart
278 | this.redraw()
279 | })
280 | this.container.append('g').classed('brush', true).call(brush)
281 | }
282 |
283 | /**
284 | * Mount a new legend if necessary
285 | * @param {String} symbolType symbol type (circle, square, line)
286 | */
287 | mountLegend(symbolType: LegendSymbol): void {
288 | if (!this.legend || !this.legend.length) return
289 | const legend = new Legend({
290 | legend: this.legend,
291 | colorScheme: this.colors,
292 | symbolType
293 | })
294 | legend.mountTo(this.target)
295 | }
296 |
297 | /**
298 | * Mount new x axis.
299 | *
300 | * @param xAxis object that can be used to overwrite parameters of the auto-generated x {@link Axis}.
301 | */
302 | mountXAxis(xAxis: Partial): void {
303 | // axis only mountable after content is mounted
304 | if (!this.content) {
305 | console.error('error: content needs to be mounted first')
306 | return
307 | }
308 |
309 | if (typeof xAxis?.show !== 'undefined' && !xAxis.show) return
310 | this.xAxis = new Axis({
311 | scale: this.xScale,
312 | orientation: AxisOrientation.BOTTOM,
313 | top: this.bottom,
314 | left: this.left,
315 | height: this.innerHeight,
316 | buffer: this.buffer,
317 | ...xAxis
318 | })
319 | if (!xAxis?.tickFormat) this.computeXAxisType()
320 |
321 | // attach axis
322 | if (this.xAxis) this.xAxis.mountTo(this.content)
323 | }
324 |
325 | /**
326 | * Mount new y axis.
327 | *
328 | * @param yAxis object that can be used to overwrite parameters of the auto-generated y {@link Axis}.
329 | */
330 | mountYAxis(yAxis: Partial): void {
331 | // axis only mountable after content is mounted
332 | if (!this.content) {
333 | console.error('error: content needs to be mounted first')
334 | return
335 | }
336 |
337 | if (typeof yAxis?.show !== 'undefined' && !yAxis.show) return
338 | this.yAxis = new Axis({
339 | scale: this.yScale,
340 | orientation: AxisOrientation.LEFT,
341 | top: this.top,
342 | left: this.left,
343 | height: this.innerWidth,
344 | buffer: this.buffer,
345 | ...yAxis
346 | })
347 | if (!yAxis?.tickFormat) this.computeYAxisType()
348 | if (this.yAxis) this.yAxis.mountTo(this.content)
349 | }
350 |
351 | /**
352 | * Mount a new tooltip if necessary.
353 | *
354 | * @param showTooltip whether or not to show a tooltip.
355 | * @param tooltipFunction function that receives a data object and returns the string displayed as tooltip.
356 | */
357 | mountTooltip(showTooltip?: boolean, tooltipFunction?: TooltipFunction): void {
358 | // only mount of content is defined
359 | if (!this.content) {
360 | console.error('error: content is not defined yet')
361 | return
362 | }
363 |
364 | if (typeof showTooltip !== 'undefined' && !showTooltip) return
365 | this.tooltip = new Tooltip({
366 | top: this.buffer,
367 | left: this.width - 2 * this.buffer,
368 | xAccessor: this.xAccessor,
369 | yAccessor: this.yAccessor,
370 | textFunction: tooltipFunction,
371 | colors: this.colors,
372 | legend: this.legend
373 | })
374 | this.tooltip.mountTo(this.content)
375 | }
376 |
377 | /**
378 | * Mount the main container.
379 | */
380 | mountContainer(): void {
381 | // content needs to be mounted first
382 | if (!this.content) {
383 | console.error('content needs to be mounted first')
384 | return
385 | }
386 |
387 | const width = max(this.xScale.range)
388 | const height = max(this.yScale.range)
389 |
390 | if (!width || !height) {
391 | console.error(`error: width or height is null (width: "${width}", height: "${height}")`)
392 | return
393 | }
394 |
395 | this.container = this.content
396 | .append('g')
397 | .attr('transform', `translate(${this.left},${this.top})`)
398 | .attr('clip-path', `url(#mg-plot-window-${this.id})`)
399 | .append('g')
400 | .attr('transform', `translate(${this.buffer},${this.buffer})`)
401 | this.container
402 | .append('rect')
403 | .attr('x', 0)
404 | .attr('y', 0)
405 | .attr('opacity', 0)
406 | .attr('pointer-events', 'all')
407 | .attr('width', width)
408 | .attr('height', height)
409 | }
410 |
411 | /**
412 | * This method is called by the abstract chart constructor.
413 | * Append the local svg node to the specified target, if necessary.
414 | * Return existing svg node if it's already present.
415 | */
416 | mountSvg(): void {
417 | const svg = this.target.select('svg')
418 |
419 | // warn user if svg is not empty
420 | if (!svg.empty()) {
421 | console.warn('Warning: SVG is not empty. Rendering might be unnecessary.')
422 | }
423 |
424 | // clear svg
425 | svg.remove()
426 |
427 | this.svg = this.target.append('svg').classed('mg-graph', true).attr('width', this.width).attr('height', this.height)
428 |
429 | // prepare clip path
430 | this.svg.select('.mg-clip-path').remove()
431 | this.svg
432 | .append('defs')
433 | .attr('class', 'mg-clip-path')
434 | .append('clipPath')
435 | .attr('id', `mg-plot-window-${this.id}`)
436 | .append('svg:rect')
437 | .attr('width', this.width - this.margin.left - this.margin.right)
438 | .attr('height', this.height - this.margin.top - this.margin.bottom)
439 |
440 | // set viewbox
441 | this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`)
442 |
443 | // append content
444 | this.content = this.svg.append('g').classed('mg-content', true)
445 | }
446 |
447 | /**
448 | * If needed, charts can implement data normalizations, which are applied when instantiating a new chart.
449 | * TODO this is currently unused
450 | */
451 | // abstract normalizeData(): void
452 |
453 | /**
454 | * Usually, the domains of the chart's scales depend on the chart type and the passed data, so this should usually be overwritten by chart implementations.
455 | * @returns domains for x and y axis as separate properties.
456 | */
457 | computeDomains(): DomainObject {
458 | const flatData = this.data.flat()
459 | const x = extent(flatData, this.xAccessor)
460 | const y = extent(flatData, this.yAccessor)
461 | return { x: x as [number, number], y: y as [number, number] }
462 | }
463 |
464 | /**
465 | * Set tick format of the x axis.
466 | */
467 | computeXAxisType(): void {
468 | // abort if no x axis is used
469 | if (!this.xAxis) {
470 | console.error('error: no x axis set')
471 | return
472 | }
473 |
474 | const flatData = this.data.flat()
475 | const xValue = this.xAccessor(flatData[0])
476 |
477 | if (xValue instanceof Date) {
478 | this.xAxis.tickFormat = 'date'
479 | } else if (Number(xValue) === xValue) {
480 | this.xAxis.tickFormat = 'number'
481 | }
482 | }
483 |
484 | /**
485 | * Set tick format of the y axis.
486 | */
487 | computeYAxisType(): void {
488 | // abort if no y axis is used
489 | if (!this.yAxis) {
490 | console.error('error: no y axis set')
491 | return
492 | }
493 |
494 | const flatData = this.data.flat()
495 | const yValue = this.yAccessor(flatData[0])
496 |
497 | if (yValue instanceof Date) {
498 | this.yAxis.tickFormat = constants.axisFormat.date
499 | } else if (Number(yValue) === yValue) {
500 | this.yAxis.tickFormat = constants.axisFormat.number
501 | }
502 | }
503 |
504 | generatePoint(args: Partial): Point {
505 | return new Point({
506 | ...args,
507 | xAccessor: this.xAccessor,
508 | yAccessor: this.yAccessor,
509 | xScale: this.xScale,
510 | yScale: this.yScale
511 | })
512 | }
513 |
514 | get top(): number {
515 | return this.margin.top
516 | }
517 |
518 | get left(): number {
519 | return this.margin.left
520 | }
521 |
522 | get bottom(): number {
523 | return this.height - this.margin.bottom
524 | }
525 |
526 | // returns the pixel location of the respective side of the plot area.
527 | get plotTop(): number {
528 | return this.top + this.buffer
529 | }
530 |
531 | get plotLeft(): number {
532 | return this.left + this.buffer
533 | }
534 |
535 | get innerWidth(): number {
536 | return this.width - this.margin.left - this.margin.right - 2 * this.buffer
537 | }
538 |
539 | get innerHeight(): number {
540 | return this.height - this.margin.top - this.margin.bottom - 2 * this.buffer
541 | }
542 | }
543 |
--------------------------------------------------------------------------------
/lib/src/charts/histogram.ts:
--------------------------------------------------------------------------------
1 | import { max, bin } from 'd3'
2 | import Delaunay from '../components/delaunay'
3 | import Rect from '../components/rect'
4 | import { TooltipSymbol } from '../components/tooltip'
5 | import { LegendSymbol, InteractionFunction } from '../misc/typings'
6 | import AbstractChart, { IAbstractChart } from './abstractChart'
7 |
8 | interface IHistogramChart extends IAbstractChart {
9 | binCount?: number
10 | }
11 |
12 | /**
13 | * Creates a new histogram graph.
14 | *
15 | * @param {Object} args argument object. See {@link AbstractChart} for general parameters.
16 | * @param {Number} [args.binCount] approximate number of bins that should be used for the histogram. Defaults to what d3.bin thinks is best.
17 | */
18 | export default class HistogramChart extends AbstractChart {
19 | bins: Array
20 | rects?: Array
21 | delaunay?: any
22 | delaunayBar?: any
23 | _activeBar = -1
24 |
25 | constructor({ binCount, ...args }: IHistogramChart) {
26 | super({
27 | ...args,
28 | computeDomains: () => {
29 | // set up histogram
30 | const dataBin = bin()
31 | if (binCount) dataBin.thresholds(binCount)
32 | const bins = dataBin(args.data)
33 |
34 | // update domains
35 | return {
36 | x: [0, bins.length],
37 | y: [0, max(bins, (bin: Array) => +bin.length)!]
38 | }
39 | }
40 | })
41 |
42 | // set up histogram
43 | const dataBin = bin()
44 | if (binCount) dataBin.thresholds(binCount)
45 | this.bins = dataBin(this.data)
46 |
47 | this.redraw()
48 | }
49 |
50 | redraw(): void {
51 | // set up histogram rects
52 | this.mountRects()
53 |
54 | // set tooltip type
55 | if (this.tooltip) {
56 | this.tooltip.update({ legendObject: TooltipSymbol.SQUARE })
57 | this.tooltip.hide()
58 | }
59 |
60 | // generate delaunator
61 | this.mountDelaunay()
62 |
63 | // mount legend if any
64 | this.mountLegend(LegendSymbol.SQUARE)
65 |
66 | // mount brush if necessary
67 | this.mountBrush(this.brush)
68 | }
69 |
70 | /**
71 | * Mount the histogram rectangles.
72 | */
73 | mountRects(): void {
74 | this.rects = this.bins.map((bin) => {
75 | const rect = new Rect({
76 | data: bin,
77 | xScale: this.xScale,
78 | yScale: this.yScale,
79 | color: this.colors[0],
80 | fillOpacity: 0.5,
81 | strokeWidth: 0,
82 | xAccessor: (bin) => bin.x0,
83 | yAccessor: (bin) => bin.length,
84 | widthAccessor: (bin) => this.xScale.scaleObject(bin.x1)! - this.xScale.scaleObject(bin.x0)!,
85 | heightAccessor: (bin) => -bin.length
86 | })
87 | rect.mountTo(this.container!)
88 | return rect
89 | })
90 | }
91 |
92 | /**
93 | * Handle move events from the delaunay triangulation.
94 | *
95 | * @returns handler function.
96 | */
97 | onPointHandler(): InteractionFunction {
98 | return ([point]) => {
99 | this.activeBar = point.index
100 |
101 | // set tooltip if necessary
102 | if (!this.tooltip) return
103 | this.tooltip.update({ data: [point] })
104 | }
105 | }
106 |
107 | /**
108 | * Handle leaving the delaunay triangulation area.
109 | *
110 | * @returns handler function.
111 | */
112 | onLeaveHandler() {
113 | return () => {
114 | this.activeBar = -1
115 | if (this.tooltip) this.tooltip.hide()
116 | }
117 | }
118 |
119 | /**
120 | * Mount new delaunay triangulation.
121 | */
122 | mountDelaunay(): void {
123 | this.delaunayBar = new Rect({
124 | xScale: this.xScale,
125 | yScale: this.yScale,
126 | xAccessor: (bin) => bin.x0,
127 | yAccessor: (bin) => bin.length,
128 | widthAccessor: (bin) => bin.x1 - bin.x0,
129 | heightAccessor: (bin) => -bin.length
130 | })
131 | this.delaunay = new Delaunay({
132 | points: this.bins.map((bin) => ({
133 | x: (bin.x1 + bin.x0) / 2,
134 | y: 0,
135 | time: bin.x0,
136 | count: bin.length
137 | })),
138 | xAccessor: (d) => d.x,
139 | yAccessor: (d) => d.y,
140 | xScale: this.xScale,
141 | yScale: this.yScale,
142 | onPoint: this.onPointHandler(),
143 | onLeave: this.onLeaveHandler()
144 | })
145 | this.delaunay.mountTo(this.container)
146 | }
147 |
148 | get activeBar() {
149 | return this._activeBar
150 | }
151 |
152 | set activeBar(i: number) {
153 | // if rexts are not set yet, abort
154 | if (!this.rects) {
155 | console.error('error: can not set active bar, rects are empty')
156 | return
157 | }
158 |
159 | // if a bar was previously set, de-set it
160 | if (this._activeBar !== -1) {
161 | this.rects[this._activeBar].update({ fillOpacity: 0.5 })
162 | }
163 |
164 | // set state
165 | this._activeBar = i
166 |
167 | // set point to active
168 | if (i !== -1) this.rects[i].update({ fillOpacity: 1 })
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/lib/src/charts/line.ts:
--------------------------------------------------------------------------------
1 | import Line from '../components/line'
2 | import Area from '../components/area'
3 | import constants from '../misc/constants'
4 | import Delaunay, { IDelaunay } from '../components/delaunay'
5 | import { makeAccessorFunction } from '../misc/utility'
6 | import { AccessorFunction, LegendSymbol, InteractionFunction, EmptyInteractionFunction } from '../misc/typings'
7 | import { IPoint } from '../components/point'
8 | import { TooltipSymbol } from '../components/tooltip'
9 | import AbstractChart, { IAbstractChart } from './abstractChart'
10 |
11 | type ConfidenceBand = [AccessorFunction | string, AccessorFunction | string]
12 |
13 | interface ILineChart extends IAbstractChart {
14 | /** specifies for which sub-array of data an area should be shown. Boolean if data is a simple array */
15 | area?: Array | boolean
16 |
17 | /** array with two elements specifying how to access the lower (first) and upper (second) value for the confidence band. The two elements work like accessors and are either a string or a function */
18 | confidenceBand?: ConfidenceBand
19 |
20 | /** custom parameters passed to the voronoi generator */
21 | voronoi?: Partial
22 |
23 | /** function specifying whether or not to show a given datapoint */
24 | defined?: (point: any) => boolean
25 |
26 | /** accessor specifying for a given data point whether or not to show it as active */
27 | activeAccessor?: AccessorFunction | string
28 |
29 | /** custom parameters passed to the active point generator. See {@see Point} for a list of parameters */
30 | activePoint?: Partial
31 | }
32 |
33 | export default class LineChart extends AbstractChart {
34 | delaunay?: Delaunay
35 | defined?: (point: any) => boolean
36 | activeAccessor?: AccessorFunction
37 | activePoint?: Partial
38 | area?: Array | boolean
39 | confidenceBand?: ConfidenceBand
40 | delaunayParams?: Partial
41 |
42 | // one delaunay point per line
43 | delaunayPoints: Array = []
44 |
45 | constructor({ area, confidenceBand, voronoi, defined, activeAccessor, activePoint, ...args }: ILineChart) {
46 | super(args)
47 |
48 | // if data is not a 2d array, die
49 | if (!Array.isArray(args.data[0])) throw new Error('data is not a 2-dimensional array.')
50 |
51 | if (defined) this.defined = defined
52 | if (activeAccessor) this.activeAccessor = makeAccessorFunction(activeAccessor)
53 | this.activePoint = activePoint ?? this.activePoint
54 | this.area = area ?? this.area
55 | this.confidenceBand = confidenceBand ?? this.confidenceBand
56 | this.delaunayParams = voronoi ?? this.delaunayParams
57 |
58 | this.redraw()
59 | }
60 |
61 | redraw(): void {
62 | this.mountLines()
63 | this.mountActivePoints(this.activePoint ?? {})
64 |
65 | // generate areas if necessary
66 | this.mountAreas(this.area || false)
67 |
68 | // set tooltip type
69 | if (this.tooltip) {
70 | this.tooltip.update({ legendObject: TooltipSymbol.LINE })
71 | this.tooltip.hide()
72 | }
73 |
74 | // generate confidence band if necessary
75 | if (this.confidenceBand) {
76 | this.mountConfidenceBand(this.confidenceBand)
77 | }
78 |
79 | // add markers and baselines
80 | this.mountMarkers()
81 | this.mountBaselines()
82 |
83 | // set up delaunay triangulation
84 | this.mountDelaunay(this.delaunayParams ?? {})
85 |
86 | // mount legend if any
87 | this.mountLegend(LegendSymbol.LINE)
88 |
89 | // mount brush if necessary
90 | this.mountBrush(this.brush)
91 | }
92 |
93 | /**
94 | * Mount lines for each array of data points.
95 | */
96 | mountLines(): void {
97 | // abort if container is not defined yet
98 | if (!this.container) {
99 | console.error('error: container is not defined yet')
100 | return
101 | }
102 |
103 | // compute lines and delaunay points
104 | this.data.forEach((lineData, index) => {
105 | const line = new Line({
106 | data: lineData,
107 | xAccessor: this.xAccessor,
108 | yAccessor: this.yAccessor,
109 | xScale: this.xScale,
110 | yScale: this.yScale,
111 | color: this.colors[index],
112 | defined: this.defined
113 | })
114 | this.delaunayPoints[index] = this.generatePoint({ radius: 3 })
115 | line.mountTo(this.container!)
116 | })
117 | }
118 |
119 | /**
120 | * If an active accessor is specified, mount active points.
121 | * @param params custom parameters for point generation. See {@see Point} for a list of options.
122 | */
123 | mountActivePoints(params: Partial): void {
124 | // abort if container is not defined yet
125 | if (!this.container) {
126 | console.error('error: container is not defined yet')
127 | return
128 | }
129 |
130 | if (!this.activeAccessor) return
131 | this.data.forEach((pointArray, index) => {
132 | pointArray.filter(this.activeAccessor).forEach((data: any) => {
133 | const point = this.generatePoint({
134 | data,
135 | color: this.colors[index],
136 | radius: 3,
137 | ...params
138 | })
139 | point.mountTo(this.container!)
140 | })
141 | })
142 | }
143 |
144 | /**
145 | * Mount all specified areas.
146 | *
147 | * @param area specifies for which sub-array of data an area should be shown. Boolean if data is a simple array.
148 | */
149 | mountAreas(area: Array | boolean): void {
150 | if (typeof area === 'undefined') return
151 |
152 | let areas: Array = []
153 | const areaGenerator = (lineData: any, index: number) =>
154 | new Area({
155 | data: lineData,
156 | xAccessor: this.xAccessor,
157 | yAccessor: this.yAccessor,
158 | xScale: this.xScale,
159 | yScale: this.yScale,
160 | color: this.colors[index],
161 | defined: this.defined
162 | })
163 |
164 | // if area is boolean and truthy, generate areas for each line
165 | if (typeof area === 'boolean' && area) {
166 | areas = this.data.map(areaGenerator)
167 |
168 | // if area is array, only show areas for the truthy lines
169 | } else if (Array.isArray(area)) {
170 | areas = this.data.filter((lineData, index) => area[index]).map(areaGenerator)
171 | }
172 |
173 | // mount areas
174 | areas.forEach((area) => area.mountTo(this.container))
175 | }
176 |
177 | /**
178 | * Mount the confidence band specified by two accessors.
179 | *
180 | * @param lowerAccessor for the lower confidence bound. Either a string (specifying the property of the object representing the lower bound) or a function (returning the lower bound when given a data point).
181 | * @param upperAccessor for the upper confidence bound. Either a string (specifying the property of the object representing the upper bound) or a function (returning the upper bound when given a data point).
182 | */
183 | mountConfidenceBand([lowerAccessor, upperAccessor]: ConfidenceBand): void {
184 | // abort if container is not set
185 | if (!this.container) {
186 | console.error('error: container not defined yet')
187 | return
188 | }
189 |
190 | const confidenceBandGenerator = new Area({
191 | data: this.data[0], // confidence band only makes sense for one line
192 | xAccessor: this.xAccessor,
193 | y0Accessor: makeAccessorFunction(lowerAccessor),
194 | yAccessor: makeAccessorFunction(upperAccessor),
195 | xScale: this.xScale,
196 | yScale: this.yScale,
197 | color: '#aaa'
198 | })
199 | confidenceBandGenerator.mountTo(this.container)
200 | }
201 |
202 | /**
203 | * Mount markers, if any.
204 | */
205 | mountMarkers(): void {
206 | // abort if content is not set yet
207 | if (!this.content) {
208 | console.error('error: content container not set yet')
209 | return
210 | }
211 |
212 | const markerContainer = this.content.append('g').attr('transform', `translate(${this.left},${this.top})`)
213 | this.markers.forEach((marker) => {
214 | const x = this.xScale.scaleObject(this.xAccessor(marker))
215 | markerContainer
216 | .append('line')
217 | .classed('line-marker', true)
218 | .attr('x1', x!)
219 | .attr('x2', x!)
220 | .attr('y1', this.yScale.range[0] + this.buffer)
221 | .attr('y2', this.yScale.range[1] + this.buffer)
222 | markerContainer.append('text').classed('text-marker', true).attr('x', x!).attr('y', 8).text(marker.label)
223 | })
224 | }
225 |
226 | mountBaselines(): void {
227 | // abort if content is not set yet
228 | if (!this.content) {
229 | console.error('error: content container not set yet')
230 | return
231 | }
232 |
233 | const baselineContainer = this.content.append('g').attr('transform', `translate(${this.left},${this.top})`)
234 | this.baselines.forEach((baseline) => {
235 | const y = this.yScale.scaleObject(this.yAccessor(baseline))
236 | baselineContainer
237 | .append('line')
238 | .classed('line-baseline', true)
239 | .attr('x1', this.xScale.range[0] + this.buffer)
240 | .attr('x2', this.xScale.range[1] + this.buffer)
241 | .attr('y1', y!)
242 | .attr('y2', y!)
243 | baselineContainer
244 | .append('text')
245 | .classed('text-baseline', true)
246 | .attr('x', this.xScale.range[1] + this.buffer)
247 | .attr('y', y! - 2)
248 | .text(baseline.label)
249 | })
250 | }
251 |
252 | /**
253 | * Handle incoming points from the delaunay move handler.
254 | *
255 | * @returns handler function.
256 | */
257 | onPointHandler(): InteractionFunction {
258 | return (points) => {
259 | // pre-hide all points
260 | this.delaunayPoints.forEach((dp) => dp.dismount())
261 |
262 | points.forEach((point) => {
263 | const index = point.arrayIndex || 0
264 |
265 | // set hover point
266 | this.delaunayPoints[index].update({
267 | data: point,
268 | color: this.colors[index]
269 | })
270 | this.delaunayPoints[index].mountTo(this.container)
271 | })
272 |
273 | // set tooltip if necessary
274 | if (!this.tooltip) return
275 | this.tooltip.update({ data: points })
276 | }
277 | }
278 |
279 | /**
280 | * Handles leaving the delaunay area.
281 | *
282 | * @returns handler function.
283 | */
284 | onLeaveHandler(): EmptyInteractionFunction {
285 | return () => {
286 | this.delaunayPoints.forEach((dp) => dp.dismount())
287 | if (this.tooltip) this.tooltip.hide()
288 | }
289 | }
290 |
291 | /**
292 | * Mount a new delaunay triangulation instance.
293 | *
294 | * @param customParameters custom parameters for {@link Delaunay}.
295 | */
296 | mountDelaunay(customParameters: Partial): void {
297 | // abort if container is not set yet
298 | if (!this.container) {
299 | console.error('error: container not set yet')
300 | return
301 | }
302 |
303 | this.delaunay = new Delaunay({
304 | points: this.data,
305 | xAccessor: this.xAccessor,
306 | yAccessor: this.yAccessor,
307 | xScale: this.xScale,
308 | yScale: this.yScale,
309 | onPoint: this.onPointHandler(),
310 | onLeave: this.onLeaveHandler(),
311 | defined: this.defined,
312 | ...customParameters
313 | })
314 | this.delaunay.mountTo(this.container)
315 | }
316 |
317 | computeYAxisType(): void {
318 | // abort if no y axis is used
319 | if (!this.yAxis) {
320 | console.error('error: no y axis set')
321 | return
322 | }
323 |
324 | const flatData = this.data.flat()
325 | const yValue = this.yAccessor(flatData[0])
326 |
327 | if (yValue instanceof Date) {
328 | this.yAxis.tickFormat = constants.axisFormat.date
329 | } else if (Number(yValue) === yValue) {
330 | this.yAxis.tickFormat = constants.axisFormat.number
331 | }
332 | }
333 | }
334 |
--------------------------------------------------------------------------------
/lib/src/charts/scatter.ts:
--------------------------------------------------------------------------------
1 | import Delaunay from '../components/delaunay'
2 | import Rug, { RugOrientation } from '../components/rug'
3 | import { makeAccessorFunction } from '../misc/utility'
4 | import { AccessorFunction, LegendSymbol, InteractionFunction, EmptyInteractionFunction } from '../misc/typings'
5 | import Point from '../components/point'
6 | import { TooltipSymbol } from '../components/tooltip'
7 | import AbstractChart, { IAbstractChart } from './abstractChart'
8 |
9 | interface IScatterChart extends IAbstractChart {
10 | /** accessor specifying the size of a data point. Can be either a string (name of the size field) or a function (receiving a data point and returning its size) */
11 | sizeAccessor?: string | AccessorFunction
12 |
13 | /** whether or not to generate a rug for the x axis */
14 | xRug?: boolean
15 |
16 | /** whether or not to generate a rug for the x axis */
17 | yRug?: boolean
18 | }
19 |
20 | interface ActivePoint {
21 | i: number
22 | j: number
23 | }
24 |
25 | export default class ScatterChart extends AbstractChart {
26 | points?: Array
27 | delaunay?: Delaunay
28 | delaunayPoint?: Point
29 | sizeAccessor: AccessorFunction
30 | showXRug: boolean
31 | xRug?: Rug
32 | showYRug: boolean
33 | yRug?: Rug
34 | _activePoint: ActivePoint = { i: -1, j: -1 }
35 |
36 | constructor({ sizeAccessor, xRug, yRug, ...args }: IScatterChart) {
37 | super(args)
38 | this.showXRug = xRug ?? false
39 | this.showYRug = yRug ?? false
40 |
41 | this.sizeAccessor = sizeAccessor ? makeAccessorFunction(sizeAccessor) : () => 3
42 |
43 | this.redraw()
44 | }
45 |
46 | redraw(): void {
47 | // set up rugs if necessary
48 | this.mountRugs()
49 |
50 | // set tooltip type
51 | if (this.tooltip) {
52 | this.tooltip.update({ legendObject: TooltipSymbol.CIRCLE })
53 | this.tooltip.hide()
54 | }
55 |
56 | // set up points
57 | this.mountPoints()
58 |
59 | // generate delaunator
60 | this.mountDelaunay()
61 |
62 | // mount legend if any
63 | this.mountLegend(LegendSymbol.CIRCLE)
64 |
65 | // mount brush if necessary
66 | this.mountBrush(this.brush)
67 | }
68 |
69 | /**
70 | * Mount new rugs.
71 | */
72 | mountRugs(): void {
73 | // if content is not set yet, abort
74 | if (!this.content) {
75 | console.error('error: content not set yet')
76 | return
77 | }
78 |
79 | if (this.showXRug) {
80 | this.xRug = new Rug({
81 | accessor: this.xAccessor,
82 | scale: this.xScale,
83 | colors: this.colors,
84 | data: this.data,
85 | left: this.plotLeft,
86 | top: this.innerHeight + this.plotTop + this.buffer,
87 | orientation: RugOrientation.HORIZONTAL // TODO how to pass tickLength etc?
88 | })
89 | this.xRug.mountTo(this.content)
90 | }
91 | if (this.showYRug) {
92 | this.yRug = new Rug({
93 | accessor: this.yAccessor,
94 | scale: this.yScale,
95 | colors: this.colors,
96 | data: this.data,
97 | left: this.left,
98 | top: this.plotTop,
99 | orientation: RugOrientation.VERTICAL
100 | })
101 | this.yRug.mountTo(this.content)
102 | }
103 | }
104 |
105 | /**
106 | * Mount scatter points.
107 | */
108 | mountPoints(): void {
109 | // if container is not set yet, abort
110 | if (!this.container) {
111 | console.error('error: container not set yet')
112 | return
113 | }
114 |
115 | this.points = this.data.map((pointSet, i) =>
116 | pointSet.map((data: any) => {
117 | const point = this.generatePoint({
118 | data,
119 | color: this.colors[i],
120 | radius: this.sizeAccessor(data) as number,
121 | fillOpacity: 0.3,
122 | strokeWidth: 1
123 | })
124 | point.mountTo(this.container!)
125 | return point
126 | })
127 | )
128 | }
129 |
130 | /**
131 | * Handle incoming points from the delaunay triangulation.
132 | *
133 | * @returns handler function
134 | */
135 | onPointHandler(): InteractionFunction {
136 | return ([point]) => {
137 | this.activePoint = { i: point.arrayIndex ?? 0, j: point.index }
138 |
139 | // set tooltip if necessary
140 | if (!this.tooltip) return
141 | this.tooltip.update({ data: [point] })
142 | }
143 | }
144 |
145 | /**
146 | * Handle leaving the delaunay area.
147 | *
148 | * @returns handler function
149 | */
150 | onLeaveHandler(): EmptyInteractionFunction {
151 | return () => {
152 | this.activePoint = { i: -1, j: -1 }
153 | if (this.tooltip) this.tooltip.hide()
154 | }
155 | }
156 |
157 | /**
158 | * Mount new delaunay triangulation instance.
159 | */
160 | mountDelaunay(): void {
161 | // if container is not set yet, abort
162 | if (!this.container) {
163 | console.error('error: container not set yet')
164 | return
165 | }
166 |
167 | this.delaunayPoint = this.generatePoint({ radius: 3 })
168 | this.delaunay = new Delaunay({
169 | points: this.data,
170 | xAccessor: this.xAccessor,
171 | yAccessor: this.yAccessor,
172 | xScale: this.xScale,
173 | yScale: this.yScale,
174 | nested: true,
175 | onPoint: this.onPointHandler(),
176 | onLeave: this.onLeaveHandler()
177 | })
178 | this.delaunay.mountTo(this.container)
179 | }
180 |
181 | get activePoint() {
182 | return this._activePoint
183 | }
184 |
185 | set activePoint({ i, j }: ActivePoint) {
186 | // abort if points are not set yet
187 | if (!this.points) {
188 | console.error('error: cannot set point, as points are not set')
189 | return
190 | }
191 |
192 | // if a point was previously set, de-set it
193 | if (this._activePoint.i !== -1 && this._activePoint.j !== -1) {
194 | this.points[this._activePoint.i][this._activePoint.j].update({
195 | fillOpacity: 0.3
196 | })
197 | }
198 |
199 | // set state
200 | this._activePoint = { i, j }
201 |
202 | // set point to active
203 | if (i !== -1 && j !== -1) this.points[i][j].update({ fillOpacity: 1 })
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/lib/src/components/abstractShape.ts:
--------------------------------------------------------------------------------
1 | import { SvgD3Selection } from '../misc/typings'
2 | import Scale from './scale'
3 |
4 | export interface IAbstractShape {
5 | /** datapoint used to generate shape */
6 | data?: any
7 |
8 | /** scale used to compute x values */
9 | xScale: Scale
10 |
11 | /** scale used to compute y values */
12 | yScale: Scale
13 |
14 | /** color used for fill and strokes */
15 | color?: string
16 |
17 | /** opacity of the shape fill */
18 | fillOpacity?: number
19 |
20 | /** width of the stroke around the shape */
21 | strokeWidth?: number
22 | }
23 |
24 | export default abstract class AbstractShape {
25 | data: any
26 | shapeObject: any
27 | xScale: Scale
28 | yScale: Scale
29 | color: string
30 | fillOpacity = 1
31 | strokeWidth = 0
32 |
33 | constructor({ data, xScale, yScale, color, fillOpacity, strokeWidth }: IAbstractShape) {
34 | this.data = data
35 | this.xScale = xScale
36 | this.yScale = yScale
37 | this.color = color ?? 'black'
38 | this.fillOpacity = fillOpacity ?? this.fillOpacity
39 | this.strokeWidth = strokeWidth ?? this.strokeWidth
40 | }
41 |
42 | /**
43 | * Render the shape and mount it to the given node.
44 | * Implemented by classes extending AbstractShape.
45 | *
46 | * @param svg D3 node to mount the shape to
47 | */
48 | abstract mountTo(svg: SvgD3Selection): void
49 |
50 | /**
51 | * Hide the shape by setting the opacity to 0. This doesn't remove the shape.
52 | */
53 | hide(): void {
54 | if (this.shapeObject) this.shapeObject.attr('opacity', 0)
55 | }
56 |
57 | /**
58 | * Update the given parameters of the object.
59 | * Implemented by classes extending AbstractShape.
60 | *
61 | * @param args parameters to be updated
62 | */
63 | abstract update(args: any): void
64 |
65 | /**
66 | * Update generic properties of the shape.
67 | * This method can be used in the implementations of {@link AbstractShape#update}.
68 | *
69 | * @param color new color of the shape.
70 | * @param fillOpacity new fill opacity of the shape.
71 | * @param strokeWidth new stroke width of the shape.
72 | */
73 | updateGeneric({
74 | color,
75 | fillOpacity,
76 | strokeWidth
77 | }: Pick): void {
78 | if (color) this.updateColor(color)
79 | if (fillOpacity) this.updateOpacity(fillOpacity)
80 | if (strokeWidth) this.updateStroke(strokeWidth)
81 | }
82 |
83 | /**
84 | * Update the color of the shape.
85 | *
86 | * @param color new color of the shape.
87 | */
88 | updateColor(color: string): void {
89 | this.color = color
90 | this.updateProp('fill', color)
91 | }
92 |
93 | /**
94 | * Update the fill opacity of the shape.
95 | *
96 | * @param fillOpacity new fill opacity of the shape.
97 | */
98 | updateOpacity(fillOpacity: number): void {
99 | this.fillOpacity = fillOpacity
100 | this.updateProp('fill-opacity', fillOpacity)
101 | }
102 |
103 | /**
104 | * Update the stroke width of the shape.
105 | *
106 | * @param strokeWidth new stroke width of the shape.
107 | */
108 | updateStroke(strokeWidth: number): void {
109 | this.strokeWidth = strokeWidth
110 | this.updateProp('stroke-width', strokeWidth)
111 | }
112 |
113 | /**
114 | * Update an attribute of the raw shape node.
115 | *
116 | * @param name attribute name
117 | * @param value new value
118 | */
119 | updateProp(name: string, value: number | string): void {
120 | if (this.shapeObject) this.shapeObject.attr(name, value)
121 | }
122 |
123 | /**
124 | * Remove the shape.
125 | */
126 | dismount(): void {
127 | if (this.shapeObject) this.shapeObject.remove()
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/lib/src/components/area.ts:
--------------------------------------------------------------------------------
1 | import { area, curveCatmullRom, CurveFactory } from 'd3'
2 | import { AccessorFunction, DefinedFunction, SvgD3Selection } from '../misc/typings'
3 | import Scale from './scale'
4 |
5 | interface IArea {
6 | /** data for which the area should be created */
7 | data: Array
8 |
9 | /** x accessor function */
10 | xAccessor: AccessorFunction
11 |
12 | /** y accessor function */
13 | yAccessor: AccessorFunction
14 |
15 | /** y base accessor function (defaults to 0) */
16 | y0Accessor?: AccessorFunction
17 |
18 | /** alternative to yAccessor */
19 | y1Accessor?: AccessorFunction
20 |
21 | /** scale used to scale elements in x direction */
22 | xScale: Scale
23 |
24 | /** scale used to scale elements in y direction */
25 | yScale: Scale
26 |
27 | /** curving function. See {@link https://github.com/d3/d3-shape#curves} for available curves in d3 */
28 | curve?: CurveFactory
29 |
30 | /** color of the area */
31 | color?: string
32 |
33 | /** specifies whether or not to show a given datapoint */
34 | defined?: DefinedFunction
35 | }
36 |
37 | export default class Area {
38 | data: Array
39 | areaObject?: any
40 | index = 0
41 | color = 'none'
42 |
43 | constructor({ data, xAccessor, yAccessor, y0Accessor, y1Accessor, xScale, yScale, curve, color, defined }: IArea) {
44 | this.data = data
45 | this.color = color ?? this.color
46 |
47 | const y0 = y0Accessor ?? ((d) => 0)
48 | const y1 = y1Accessor ?? yAccessor
49 |
50 | // set up line object
51 | this.areaObject = area()
52 | .defined((d) => {
53 | if (y0(d) === null || y1(d) === null) return false
54 | return !defined ? true : defined(d)
55 | })
56 | .x((d) => xScale.scaleObject(xAccessor(d)))
57 | .y1((d) => yScale.scaleObject(y1(d)))
58 | .y0((d) => yScale.scaleObject(y0(d)))
59 | .curve(curve ?? curveCatmullRom)
60 | }
61 |
62 | /**
63 | * Mount the area to a given d3 node.
64 | *
65 | * @param svg d3 node to mount the area to.
66 | */
67 | mountTo(svg: SvgD3Selection): void {
68 | svg.append('path').classed('mg-area', true).attr('fill', this.color).datum(this.data).attr('d', this.areaObject)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/lib/src/components/axis.ts:
--------------------------------------------------------------------------------
1 | import { axisTop, axisLeft, axisRight, axisBottom, format, timeFormat } from 'd3'
2 | import constants from '../misc/constants'
3 | import { GD3Selection, LineD3Selection, TextD3Selection, TextFunction } from '../misc/typings'
4 | import Scale from './scale'
5 |
6 | const DEFAULT_VERTICAL_OFFSET = 35
7 | const DEFAULT_HORIZONTAL_OFFSET = 45
8 |
9 | type NumberFormatFunction = (x: number) => string
10 | type DateFormatFunction = (x: Date) => string
11 | type FormatFunction = NumberFormatFunction | DateFormatFunction
12 |
13 | export enum AxisOrientation {
14 | TOP = 'top',
15 | BOTTOM = 'bottom',
16 | RIGHT = 'right',
17 | LEFT = 'left'
18 | }
19 |
20 | enum AxisFormat {
21 | DATE = 'date',
22 | NUMBER = 'number',
23 | PERCENTAGE = 'percentage'
24 | }
25 |
26 | export interface IAxis {
27 | /** scale of the axis */
28 | scale: Scale
29 |
30 | /** buffer used by the chart, necessary to compute margins */
31 | buffer: number
32 |
33 | /** whether or not to show the axis */
34 | show?: boolean
35 |
36 | /** orientation of the axis */
37 | orientation?: AxisOrientation
38 |
39 | /** optional label to place beside the axis */
40 | label?: string
41 |
42 | /** offset between label and axis */
43 | labelOffset?: number
44 |
45 | /** translation from the top of the chart's box to render the axis */
46 | top?: number
47 |
48 | /** translation from the left of the chart's to render the axis */
49 | left?: number
50 |
51 | /** can be 1) a function to format a given tick or a specifier, or 2) one of the available standard formatting types (date, number, percentage) or a string for d3-format */
52 | tickFormat?: TextFunction | AxisFormat | string
53 |
54 | /** number of ticks to render, defaults to 3 for vertical and 6 for horizontal axes */
55 | tickCount?: number
56 |
57 | /** whether or not to render a compact version of the axis (clamps the main axis line at the outermost ticks) */
58 | compact?: boolean
59 |
60 | /** prefix for tick labels */
61 | prefix?: string
62 |
63 | /** suffix for tick labels */
64 | suffix?: string
65 |
66 | /** overwrite d3's default tick lengths */
67 | tickLength?: number
68 |
69 | /** draw extended ticks into the graph (used to make a grid) */
70 | extendedTicks?: boolean
71 |
72 | /** if extended ticks are used, this parameter specifies the inner length of ticks */
73 | height?: number
74 | }
75 |
76 | export default class Axis {
77 | label = ''
78 | labelOffset = 0
79 | top = 0
80 | left = 0
81 | scale: Scale
82 | orientation = AxisOrientation.BOTTOM
83 | axisObject: any
84 | compact = false
85 | extendedTicks = false
86 | buffer = 0
87 | height = 0
88 | prefix = ''
89 | suffix = ''
90 |
91 | constructor({
92 | orientation,
93 | label,
94 | labelOffset,
95 | top,
96 | left,
97 | height,
98 | scale,
99 | tickFormat,
100 | tickCount,
101 | compact,
102 | buffer,
103 | prefix,
104 | suffix,
105 | tickLength,
106 | extendedTicks
107 | }: IAxis) {
108 | this.scale = scale
109 | this.label = label ?? this.label
110 | this.buffer = buffer ?? this.buffer
111 | this.top = top ?? this.top
112 | this.left = left ?? this.left
113 | this.height = height ?? this.height
114 | this.orientation = orientation ?? this.orientation
115 | this.compact = compact ?? this.compact
116 | this.prefix = prefix ?? this.prefix
117 | this.suffix = suffix ?? this.suffix
118 | if (typeof tickLength !== 'undefined') this.tickLength = tickLength
119 | this.extendedTicks = extendedTicks ?? this.extendedTicks
120 | this.setLabelOffset(labelOffset)
121 |
122 | this.setupAxisObject()
123 |
124 | // set or compute tickFormat
125 | if (tickFormat) this.tickFormat = tickFormat
126 | this.tickCount = tickCount ?? (this.isVertical ? 3 : 6)
127 | }
128 |
129 | /**
130 | * Set the label offset.
131 | *
132 | * @param labelOffset offset of the label.
133 | */
134 | setLabelOffset(labelOffset?: number): void {
135 | this.labelOffset =
136 | typeof labelOffset !== 'undefined'
137 | ? labelOffset
138 | : this.isVertical
139 | ? DEFAULT_HORIZONTAL_OFFSET
140 | : DEFAULT_VERTICAL_OFFSET
141 | }
142 |
143 | /**
144 | * Set up the main axis object.
145 | */
146 | setupAxisObject(): void {
147 | switch (this.orientation) {
148 | case constants.axisOrientation.top:
149 | this.axisObject = axisTop(this.scale.scaleObject)
150 | break
151 | case constants.axisOrientation.left:
152 | this.axisObject = axisLeft(this.scale.scaleObject)
153 | break
154 | case constants.axisOrientation.right:
155 | this.axisObject = axisRight(this.scale.scaleObject)
156 | break
157 | default:
158 | this.axisObject = axisBottom(this.scale.scaleObject)
159 | break
160 | }
161 | }
162 |
163 | /**
164 | * Get the domain object call function.
165 | * @returns that mounts the domain when called.
166 | */
167 | domainObject() {
168 | return (g: GD3Selection): LineD3Selection =>
169 | g
170 | .append('line')
171 | .classed('domain', true)
172 | .attr('x1', this.isVertical ? 0.5 : this.compact ? this.buffer : 0)
173 | .attr('x2', this.isVertical ? 0.5 : this.compact ? this.scale.range[1] : this.scale.range[1] + 2 * this.buffer)
174 | .attr('y1', this.isVertical ? (this.compact ? this.top + 0.5 : 0.5) : 0)
175 | .attr(
176 | 'y2',
177 | this.isVertical ? (this.compact ? this.scale.range[0] + 0.5 : this.scale.range[0] + 2 * this.buffer + 0.5) : 0
178 | )
179 | }
180 |
181 | /**
182 | * Get the label object call function.
183 | * @returns {Function} that mounts the label when called.
184 | */
185 | labelObject(): (node: GD3Selection) => TextD3Selection {
186 | const value = Math.abs(this.scale.range[0] - this.scale.range[1]) / 2
187 | const xValue = this.isVertical ? -this.labelOffset : value
188 | const yValue = this.isVertical ? value : this.labelOffset
189 | return (g) =>
190 | g
191 | .append('text')
192 | .attr('x', xValue)
193 | .attr('y', yValue)
194 | .attr('text-anchor', 'middle')
195 | .classed('label', true)
196 | .attr('transform', this.isVertical ? `rotate(${-90} ${xValue},${yValue})` : '')
197 | .text(this.label)
198 | }
199 |
200 | get isVertical(): boolean {
201 | return [constants.axisOrientation.left, constants.axisOrientation.right].includes(this.orientation)
202 | }
203 |
204 | get innerLeft(): number {
205 | return this.isVertical ? 0 : this.buffer
206 | }
207 |
208 | get innerTop(): number {
209 | return this.isVertical ? this.buffer : 0
210 | }
211 |
212 | get tickAttribute(): string {
213 | return this.isVertical ? 'x1' : 'y1'
214 | }
215 |
216 | get extendedTickLength(): number {
217 | const factor = this.isVertical ? 1 : -1
218 | return factor * (this.height + 2 * this.buffer)
219 | }
220 |
221 | /**
222 | * Mount the axis to the given d3 node.
223 | * @param svg d3 node.
224 | */
225 | mountTo(svg: GD3Selection): void {
226 | // set up axis container
227 | const axisContainer = svg
228 | .append('g')
229 | .attr('transform', `translate(${this.left},${this.top})`)
230 | .classed('mg-axis', true)
231 |
232 | // if no extended ticks are used, draw the domain line
233 | if (!this.extendedTicks) axisContainer.call(this.domainObject())
234 |
235 | // mount axis but remove default-generated domain
236 | axisContainer
237 | .append('g')
238 | .attr('transform', `translate(${this.innerLeft},${this.innerTop})`)
239 | .call(this.axisObject)
240 | .call((g) => g.select('.domain').remove())
241 |
242 | // if necessary, make ticks longer
243 | if (this.extendedTicks) {
244 | axisContainer.call((g) =>
245 | g.selectAll('.tick line').attr(this.tickAttribute, this.extendedTickLength).attr('opacity', 0.3)
246 | )
247 | }
248 |
249 | // if necessary, add label
250 | if (this.label !== '') axisContainer.call(this.labelObject())
251 | }
252 |
253 | /**
254 | * Compute the time formatting function based on the time domain.
255 | * @returns d3 function for formatting time.
256 | */
257 | diffToTimeFormat(): FormatFunction {
258 | const diff = Math.abs(this.scale.domain[1] - this.scale.domain[0]) / 1000
259 |
260 | const millisecondDiff = diff < 1
261 | const secondDiff = diff < 60
262 | const dayDiff = diff / (60 * 60) < 24
263 | const fourDaysDiff = diff / (60 * 60) < 24 * 4
264 | const manyDaysDiff = diff / (60 * 60 * 24) < 60
265 | const manyMonthsDiff = diff / (60 * 60 * 24) < 365
266 |
267 | return millisecondDiff
268 | ? timeFormat('%M:%S.%L')
269 | : secondDiff
270 | ? timeFormat('%M:%S')
271 | : dayDiff
272 | ? timeFormat('%H:%M')
273 | : fourDaysDiff || manyDaysDiff || manyMonthsDiff
274 | ? timeFormat('%b %d')
275 | : timeFormat('%Y')
276 | }
277 |
278 | /**
279 | * Get the d3 number formatting function for an abstract number type.
280 | *
281 | * @param formatType abstract format to be converted (number, date, percentage)
282 | * @returns d3 formatting function for the given abstract number type.
283 | */
284 | stringToFormat(formatType: AxisFormat | string): FormatFunction {
285 | switch (formatType) {
286 | case constants.axisFormat.number:
287 | return this.isVertical ? format('~s') : format('')
288 | case constants.axisFormat.date:
289 | return this.diffToTimeFormat()
290 | case constants.axisFormat.percentage:
291 | return format('.0%')
292 | default:
293 | return format(formatType)
294 | }
295 | }
296 |
297 | get tickFormat() {
298 | return this.axisObject.tickFormat()
299 | }
300 |
301 | set tickFormat(tickFormat: FormatFunction | string) {
302 | // if tickFormat is a function, apply it directly
303 | const formatFunction = typeof tickFormat === 'function' ? tickFormat : this.stringToFormat(tickFormat)
304 |
305 | this.axisObject.tickFormat((d: any) => `${this.prefix}${formatFunction(d)}${this.suffix}`)
306 | }
307 |
308 | get tickCount() {
309 | return this.axisObject.ticks()
310 | }
311 |
312 | set tickCount(tickCount: number) {
313 | this.axisObject.ticks(tickCount)
314 | }
315 |
316 | get tickLength() {
317 | return this.axisObject.tickSize()
318 | }
319 |
320 | set tickLength(length: number) {
321 | this.axisObject.tickSize(length)
322 | }
323 | }
324 |
--------------------------------------------------------------------------------
/lib/src/components/delaunay.ts:
--------------------------------------------------------------------------------
1 | import { Delaunay as DelaunayObject, pointer } from 'd3'
2 | import {
3 | AccessorFunction,
4 | InteractionFunction,
5 | EmptyInteractionFunction,
6 | DefinedFunction,
7 | GenericD3Selection
8 | } from '../misc/typings'
9 | import Scale from './scale'
10 |
11 | export interface IDelaunay {
12 | /** raw data basis for delaunay computations, can be nested */
13 | points: Array
14 |
15 | /** function to access the x value for a given data point */
16 | xAccessor: AccessorFunction
17 |
18 | /** function to access the y value for a given data point */
19 | yAccessor: AccessorFunction
20 |
21 | /** scale used to scale elements in x direction */
22 | xScale: Scale
23 |
24 | /** scale used to scale elements in y direction */
25 | yScale: Scale
26 |
27 | /** function called with the array of nearest points on mouse movement -- if aggregate is false, the array will contain at most one element */
28 | onPoint?: InteractionFunction
29 |
30 | /** function called when the delaunay area is left */
31 | onLeave?: EmptyInteractionFunction
32 |
33 | /** function called with the array of nearest points on mouse click in the delaunay area -- if aggregate is false, the array will contain at most one element */
34 | onClick?: InteractionFunction
35 |
36 | /** whether or not the points array contains sub-arrays */
37 | nested?: boolean
38 |
39 | /** if multiple points have the same x value and should be shown together, aggregate can be set to true */
40 | aggregate?: boolean
41 |
42 | /** optional function specifying whether or not to show a given datapoint */
43 | defined?: DefinedFunction
44 | }
45 |
46 | export default class Delaunay {
47 | points?: Array
48 | aggregatedPoints: any
49 | delaunay: any
50 | xScale: Scale
51 | yScale: Scale
52 | xAccessor: AccessorFunction
53 | yAccessor: AccessorFunction
54 | aggregate = false
55 | onPoint: InteractionFunction
56 | onClick?: InteractionFunction
57 | onLeave: EmptyInteractionFunction
58 |
59 | constructor({
60 | points,
61 | xAccessor,
62 | yAccessor,
63 | xScale,
64 | yScale,
65 | onPoint,
66 | onLeave,
67 | onClick,
68 | nested,
69 | aggregate,
70 | defined
71 | }: IDelaunay) {
72 | this.xAccessor = xAccessor
73 | this.yAccessor = yAccessor
74 | this.xScale = xScale
75 | this.yScale = yScale
76 | this.onPoint = onPoint ?? (() => null)
77 | this.onLeave = onLeave ?? (() => null)
78 | this.onClick = onClick ?? this.onClick
79 | this.aggregate = aggregate ?? this.aggregate
80 |
81 | // normalize passed points
82 | const isNested = nested ?? (Array.isArray(points[0]) && points.length > 1)
83 | this.normalizePoints({ points, nested: isNested, aggregate, defined })
84 |
85 | // set up delaunay
86 | this.mountDelaunay(isNested, this.aggregate)
87 | }
88 |
89 | /**
90 | * Create a new delaunay triangulation.
91 | *
92 | * @param isNested whether or not the data is nested
93 | * @param aggregate whether or not to aggregate points based on their x value
94 | */
95 | mountDelaunay(isNested: boolean, aggregate: boolean): void {
96 | // if points are not set yet, stop
97 | if (!this.points) {
98 | console.error('error: points not defined')
99 | return
100 | }
101 |
102 | this.delaunay = DelaunayObject.from(
103 | this.points.map((point) => [
104 | this.xAccessor(point) as number,
105 | (isNested && !aggregate ? this.yAccessor(point) : 0) as number
106 | ])
107 | )
108 | }
109 |
110 | /**
111 | * Normalize the passed data points.
112 | *
113 | * @param {Object} args argument object
114 | * @param {Array} args.points raw data array
115 | * @param {Boolean} args.isNested whether or not the points are nested
116 | * @param {Boolean} args.aggregate whether or not to aggregate points based on their x value
117 | * @param {Function} [args.defined] optional function specifying whether or not to show a given datapoint.
118 | */
119 | normalizePoints({
120 | points,
121 | nested,
122 | aggregate,
123 | defined
124 | }: Pick): void {
125 | this.points = nested
126 | ? points
127 | .map((pointArray, arrayIndex) =>
128 | pointArray
129 | .filter((p: any) => !defined || defined(p))
130 | .map((point: any, index: number) => ({
131 | ...point,
132 | index,
133 | arrayIndex
134 | }))
135 | )
136 | .flat(Infinity)
137 | : points
138 | .flat(Infinity)
139 | .filter((p) => !defined || defined(p))
140 | .map((p, index) => ({ ...p, index }))
141 |
142 | // if points should be aggregated, hash-map them based on their x accessor value
143 | if (!aggregate) return
144 | this.aggregatedPoints = this.points.reduce((acc, val) => {
145 | const key = JSON.stringify(this.xAccessor(val))
146 | if (!acc.has(key)) {
147 | acc.set(key, [val])
148 | } else {
149 | acc.set(key, [val, ...acc.get(key)])
150 | }
151 | return acc
152 | }, new Map())
153 | }
154 |
155 | /**
156 | * Handle raw mouse movement inside the delaunay rect.
157 | * Finds the nearest data point(s) and calls onPoint.
158 | *
159 | * @param rawX raw x coordinate of the cursor.
160 | * @param rawY raw y coordinate of the cursor.
161 | */
162 | gotPoint(rawX: number, rawY: number): void {
163 | // if points are empty, return
164 | if (!this.points) {
165 | console.error('error: points are empty')
166 | return
167 | }
168 |
169 | const x = this.xScale.scaleObject.invert(rawX)
170 | const y = this.yScale.scaleObject.invert(rawY)
171 |
172 | // find nearest point
173 | const index = this.delaunay.find(x, y)
174 |
175 | // if points should be aggregated, get all points with the same x value
176 | if (this.aggregate) {
177 | this.onPoint(this.aggregatedPoints.get(JSON.stringify(this.xAccessor(this.points[index]))))
178 | } else {
179 | this.onPoint([this.points[index]])
180 | }
181 | }
182 |
183 | /**
184 | * Handle raw mouse clicks inside the delaunay rect.
185 | * Finds the nearest data point(s) and calls onClick.
186 | *
187 | * @param rawX raw x coordinate of the cursor.
188 | * @param rawY raw y coordinate of the cursor.
189 | */
190 | clickedPoint(rawX: number, rawY: number): void {
191 | // if points empty, abort
192 | if (!this.points) {
193 | console.error('error: points empty')
194 | return
195 | }
196 |
197 | const x = this.xScale.scaleObject.invert(rawX)
198 | const y = this.yScale.scaleObject.invert(rawY)
199 |
200 | // find nearest point
201 | const index = this.delaunay.find(x, y)
202 | if (this.onClick) this.onClick({ ...this.points[index], index })
203 | }
204 |
205 | /**
206 | * Mount the delaunator to a given d3 node.
207 | *
208 | * @param svg d3 selection to mount the delaunay elements to.
209 | */
210 | mountTo(svg: GenericD3Selection): void {
211 | svg.on('mousemove', (event) => {
212 | const coords = pointer(event)
213 | this.gotPoint(coords[0], coords[1])
214 | })
215 | svg.on('mouseleave', () => {
216 | this.onLeave()
217 | })
218 | svg.on('click', (event) => {
219 | const coords = pointer(event)
220 | this.clickedPoint(coords[0], coords[1])
221 | })
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/lib/src/components/legend.ts:
--------------------------------------------------------------------------------
1 | import { select } from 'd3'
2 | import constants from '../misc/constants'
3 | import { LegendSymbol } from '../misc/typings'
4 |
5 | interface ILegend {
6 | /** array of descriptive legend strings */
7 | legend: Array
8 |
9 | /** colors used for the legend -- will be darkened for better visibility */
10 | colorScheme: Array
11 |
12 | /** symbol used in the legend */
13 | symbolType: LegendSymbol
14 | }
15 |
16 | export default class Legend {
17 | legend: Array
18 | colorScheme: Array
19 | symbolType: LegendSymbol
20 |
21 | constructor({ legend, colorScheme, symbolType }: ILegend) {
22 | this.legend = legend
23 | this.colorScheme = colorScheme
24 | this.symbolType = symbolType
25 | }
26 |
27 | /**
28 | * Darken a given color by a given amount.
29 | *
30 | * @see https://css-tricks.com/snippets/javascript/lighten-darken-color/
31 | * @param color hex color specifier
32 | * @param amount how much to darken the color
33 | * @returns darkened color in hex representation.
34 | */
35 | darkenColor(color: string, amount: number): string {
36 | // remove hash
37 | color = color.slice(1)
38 |
39 | const num = parseInt(color, 16)
40 |
41 | const r = this.clamp((num >> 16) + amount)
42 | const b = this.clamp(((num >> 8) & 0x00ff) + amount)
43 | const g = this.clamp((num & 0x0000ff) + amount)
44 |
45 | return '#' + (g | (b << 8) | (r << 16)).toString(16)
46 | }
47 |
48 | /**
49 | * Clamp a number between 0 and 255.
50 | *
51 | * @param number number to be clamped.
52 | * @returns clamped number.
53 | */
54 | clamp(number: number): number {
55 | return number > 255 ? 255 : number < 0 ? 0 : number
56 | }
57 |
58 | /**
59 | * Mount the legend to the given node.
60 | *
61 | * @param node d3 specifier or d3 node to mount the legend to.
62 | */
63 | mountTo(node: any) {
64 | const symbol = constants.symbol[this.symbolType]
65 |
66 | // create d3 selection if necessary
67 | const target = typeof node === 'string' ? select(node).append('div') : node.append('div')
68 | target.classed('mg-legend', true)
69 |
70 | this.legend.forEach((item, index) => {
71 | target
72 | .append('span')
73 | .classed('text-legend', true)
74 | .style('color', this.darkenColor(this.colorScheme[index], -10))
75 | .text(`${symbol} ${item}`)
76 | })
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/lib/src/components/line.ts:
--------------------------------------------------------------------------------
1 | import { line, curveCatmullRom, CurveFactory } from 'd3'
2 | import { AccessorFunction, SvgD3Selection } from '../misc/typings'
3 | import Scale from './scale'
4 |
5 | interface ILine {
6 | /** array of datapoints used to create the line */
7 | data: Array
8 |
9 | /** function to access the x value for a given datapoint */
10 | xAccessor: AccessorFunction
11 |
12 | /** function to access the y value for a given datapoint */
13 | yAccessor: AccessorFunction
14 |
15 | /** scale used to compute x values */
16 | xScale: Scale
17 |
18 | /** scale used to compute y values */
19 | yScale: Scale
20 |
21 | /** curving function used to draw the line. See {@link https://github.com/d3/d3-shape#curves} for curves available in d3 */
22 | curve?: CurveFactory
23 |
24 | /** color of the line */
25 | color?: string
26 |
27 | /** function specifying whether or not to show a given datapoint */
28 | defined?: (datapoint: any) => boolean
29 | }
30 |
31 | export default class Line {
32 | lineObject?: any
33 | data: Array
34 | color: string
35 |
36 | constructor({ data, xAccessor, yAccessor, xScale, yScale, curve, color, defined }: ILine) {
37 | // cry if no data was passed
38 | if (!data) throw new Error('line needs data')
39 | this.data = data
40 | this.color = color ?? 'black'
41 |
42 | // set up line object
43 | this.lineObject = line()
44 | .defined((d) => {
45 | if (yAccessor(d) === null) return false
46 | if (typeof defined === 'undefined') return true
47 | return defined(d)
48 | })
49 | .x((d) => xScale.scaleObject(xAccessor(d)))
50 | .y((d) => yScale.scaleObject(yAccessor(d)))
51 | .curve(curve ?? curveCatmullRom)
52 | }
53 |
54 | /**
55 | * Mount the line to the given d3 node.
56 | *
57 | * @param svg d3 node to mount the line to.
58 | */
59 | mountTo(svg: SvgD3Selection): void {
60 | svg.append('path').classed('mg-line', true).attr('stroke', this.color).datum(this.data).attr('d', this.lineObject)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/lib/src/components/point.ts:
--------------------------------------------------------------------------------
1 | import { AccessorFunction, SvgD3Selection } from '../misc/typings'
2 | import AbstractShape, { IAbstractShape } from './abstractShape'
3 |
4 | export interface IPoint extends IAbstractShape {
5 | /** function to access the x value of the point */
6 | xAccessor: AccessorFunction
7 |
8 | /** function to access the y value of the point */
9 | yAccessor: AccessorFunction
10 |
11 | /** radius of the point */
12 | radius?: number
13 | }
14 |
15 | export default class Point extends AbstractShape {
16 | xAccessor: AccessorFunction
17 | yAccessor: AccessorFunction
18 | radius = 1
19 |
20 | constructor({ xAccessor, yAccessor, radius, ...args }: IPoint) {
21 | super(args)
22 | this.xAccessor = xAccessor
23 | this.yAccessor = yAccessor
24 | this.radius = radius ?? this.radius
25 | }
26 |
27 | get cx(): number {
28 | return this.xScale.scaleObject(this.xAccessor(this.data))
29 | }
30 |
31 | get cy(): number {
32 | return this.yScale.scaleObject(this.yAccessor(this.data))
33 | }
34 |
35 | /**
36 | * Mount the point to the given node.
37 | *
38 | * @param svg d3 node to mount the point to.
39 | */
40 | mountTo(svg: SvgD3Selection): void {
41 | this.shapeObject = svg
42 | .append('circle')
43 | .attr('cx', this.cx)
44 | .attr('pointer-events', 'none')
45 | .attr('cy', this.cy)
46 | .attr('r', this.radius)
47 | .attr('fill', this.color)
48 | .attr('stroke', this.color)
49 | .attr('fill-opacity', this.fillOpacity)
50 | .attr('stroke-width', this.strokeWidth)
51 | }
52 |
53 | /**
54 | * Update the point.
55 | *
56 | * @param data point object
57 | */
58 | update({ data, ...args }: IAbstractShape): void {
59 | this.updateGeneric(args)
60 | if (data) {
61 | this.data = data
62 | if (!this.shapeObject) return
63 | this.shapeObject.attr('cx', this.cx).attr('cy', this.cy).attr('opacity', 1)
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/lib/src/components/rect.ts:
--------------------------------------------------------------------------------
1 | import { AccessorFunction, GenericD3Selection } from '../misc/typings'
2 | import AbstractShape, { IAbstractShape } from './abstractShape'
3 |
4 | interface IRect extends IAbstractShape {
5 | /** function to access the x value of the rectangle */
6 | xAccessor: AccessorFunction
7 |
8 | /** function to access the y value of the rectangle */
9 | yAccessor: AccessorFunction
10 |
11 | /** function to access the width of the rectangle */
12 | widthAccessor: AccessorFunction
13 |
14 | /** function to access the height of the rectangle */
15 | heightAccessor: AccessorFunction
16 | }
17 |
18 | export default class Rect extends AbstractShape {
19 | xAccessor: AccessorFunction
20 | yAccessor: AccessorFunction
21 | widthAccessor: AccessorFunction
22 | heightAccessor: AccessorFunction
23 |
24 | constructor({ xAccessor, yAccessor, widthAccessor, heightAccessor, ...args }: IRect) {
25 | super(args)
26 | this.xAccessor = xAccessor
27 | this.yAccessor = yAccessor
28 | this.widthAccessor = widthAccessor
29 | this.heightAccessor = heightAccessor
30 | }
31 |
32 | get x(): number {
33 | return this.xScale.scaleObject(this.xAccessor(this.data))
34 | }
35 |
36 | get y(): number {
37 | return this.yScale.scaleObject(this.yAccessor(this.data))
38 | }
39 |
40 | get width(): number {
41 | return Math.max(0, Math.abs(this.widthAccessor(this.data)))
42 | }
43 |
44 | get height(): number {
45 | return Math.max(0, this.yScale.scaleObject(this.heightAccessor(this.data)))
46 | }
47 |
48 | /**
49 | * Mount the rectangle to the given node.
50 | *
51 | * @param svg d3 node to mount the rectangle to.
52 | */
53 | mountTo(svg: GenericD3Selection): void {
54 | this.shapeObject = svg
55 | .append('rect')
56 | .attr('x', this.x)
57 | .attr('y', this.y)
58 | .attr('width', this.width)
59 | .attr('height', this.height)
60 | .attr('pointer-events', 'none')
61 | .attr('fill', this.color)
62 | .attr('stroke', this.color)
63 | .attr('fill-opacity', this.fillOpacity)
64 | .attr('stroke-width', this.strokeWidth)
65 | }
66 |
67 | /**
68 | * Update the rectangle.
69 | *
70 | * @param data updated data object.
71 | */
72 | update({ data, ...args }: Partial): void {
73 | this.updateGeneric(args)
74 | if (data) {
75 | this.data = data
76 | if (!this.shapeObject) return
77 | this.shapeObject
78 | .attr('x', this.x)
79 | .attr('y', this.y)
80 | .attr('width', this.width)
81 | .attr('height', this.height)
82 | .attr('opacity', 1)
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/lib/src/components/rug.ts:
--------------------------------------------------------------------------------
1 | import constants from '../misc/constants'
2 | import { AccessorFunction, GenericD3Selection } from '../misc/typings'
3 | import Scale from './scale'
4 |
5 | export enum RugOrientation {
6 | HORIZONTAL = 'horizontal',
7 | VERTICAL = 'vertical'
8 | }
9 |
10 | interface IRug {
11 | /** accessor used to get the rug value for a given datapoint */
12 | accessor: AccessorFunction
13 |
14 | /** scale function of the rug */
15 | scale: Scale
16 |
17 | /** data to be rugged */
18 | data: Array
19 |
20 | /** length of the rug's ticks */
21 | tickLength?: number
22 |
23 | /** color scheme of the rug ticks */
24 | colors?: Array
25 |
26 | /** orientation of the rug */
27 | orientation?: RugOrientation
28 |
29 | /** left margin of the rug */
30 | left?: number
31 |
32 | /** top margin of the rug */
33 | top?: number
34 | }
35 |
36 | export default class Rug {
37 | accessor: AccessorFunction
38 | scale: Scale
39 | rugObject: any
40 | data: Array
41 | left = 0
42 | top = 0
43 | tickLength = 5
44 | colors = constants.defaultColors
45 | orientation = RugOrientation.HORIZONTAL
46 |
47 | constructor({ accessor, scale, data, tickLength, colors, orientation, left, top }: IRug) {
48 | this.accessor = accessor
49 | this.scale = scale
50 | this.data = data
51 | this.tickLength = tickLength ?? this.tickLength
52 | this.colors = colors ?? this.colors
53 | this.orientation = orientation ?? this.orientation
54 | this.left = left ?? this.left
55 | this.top = top ?? this.top
56 | }
57 |
58 | get isVertical(): boolean {
59 | return this.orientation === constants.orientation.vertical
60 | }
61 |
62 | /**
63 | * Mount the rug to the given node.
64 | *
65 | * @param svg d3 node to mount the rug to.
66 | */
67 | mountTo(svg: GenericD3Selection): void {
68 | // add container
69 | const top = this.isVertical ? this.top : this.top - this.tickLength
70 | const container = svg.append('g').attr('transform', `translate(${this.left},${top})`)
71 |
72 | // add lines
73 | this.data.forEach((dataArray, i) =>
74 | dataArray.forEach((datum: any) => {
75 | const value = this.scale.scaleObject(this.accessor(datum))
76 | container
77 | .append('line')
78 | .attr(this.isVertical ? 'y1' : 'x1', value!)
79 | .attr(this.isVertical ? 'y2' : 'x2', value!)
80 | .attr(this.isVertical ? 'x1' : 'y1', 0)
81 | .attr(this.isVertical ? 'x2' : 'y2', this.tickLength)
82 | .attr('stroke', this.colors[i])
83 | })
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/lib/src/components/scale.ts:
--------------------------------------------------------------------------------
1 | import { scaleLinear, ScaleLinear } from 'd3'
2 | import { Domain, Range } from '../misc/typings'
3 |
4 | enum ScaleType {
5 | LINEAR = 'linear'
6 | }
7 |
8 | type SupportedScale = ScaleLinear
9 |
10 | interface IScale {
11 | /** type of scale (currently only linear) */
12 | type?: ScaleType
13 |
14 | /** scale range */
15 | range?: Range
16 |
17 | /** scale domain */
18 | domain?: Domain
19 |
20 | /** overwrites domain lower bound */
21 | minValue?: number
22 |
23 | /** overwrites domain upper bound */
24 | maxValue?: number
25 | }
26 |
27 | export default class Scale {
28 | type: ScaleType
29 | scaleObject: SupportedScale
30 | minValue?: number
31 | maxValue?: number
32 |
33 | constructor({ type, range, domain, minValue, maxValue }: IScale) {
34 | this.type = type ?? ScaleType.LINEAR
35 | this.scaleObject = this.getScaleObject(this.type)
36 |
37 | // set optional custom ranges and domains
38 | if (range) this.range = range
39 | if (domain) this.domain = domain
40 |
41 | // set optional min and max
42 | this.minValue = minValue
43 | this.maxValue = maxValue
44 | }
45 |
46 | /**
47 | * Get the d3 scale object for a given scale type.
48 | *
49 | * @param {String} type scale type
50 | * @returns {Object} d3 scale type
51 | */
52 | getScaleObject(type: ScaleType): SupportedScale {
53 | switch (type) {
54 | default:
55 | return scaleLinear()
56 | }
57 | }
58 |
59 | get range(): Range {
60 | return this.scaleObject.range()
61 | }
62 |
63 | set range(range: Range) {
64 | this.scaleObject.range(range)
65 | }
66 |
67 | get domain(): Domain {
68 | return this.scaleObject.domain()
69 | }
70 |
71 | set domain(domain: Domain) {
72 | // fix custom domain values if necessary
73 | if (typeof this.minValue !== 'undefined') domain[0] = this.minValue
74 | if (typeof this.maxValue !== 'undefined') domain[1] = this.maxValue
75 | this.scaleObject.domain(domain)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/lib/src/components/tooltip.ts:
--------------------------------------------------------------------------------
1 | import constants from '../misc/constants'
2 | import { TextFunction, AccessorFunction, GenericD3Selection } from '../misc/typings'
3 |
4 | export enum TooltipSymbol {
5 | CIRCLE = 'circle',
6 | LINE = 'line',
7 | SQUARE = 'square'
8 | }
9 |
10 | interface ITooltip {
11 | /** symbol to show in the tooltip (defaults to line) */
12 | legendObject?: TooltipSymbol
13 |
14 | /** description of the different data arrays shown in the legend */
15 | legend?: Array
16 |
17 | /** array of colors for the different data arrays, defaults to schemeCategory10 */
18 | colors?: Array
19 |
20 | /** custom text formatting function -- generated from accessors if not defined */
21 | textFunction?: TextFunction
22 |
23 | /** entries to show in the tooltip, usually empty when first instantiating */
24 | data?: Array
25 |
26 | /** margin to the left of the tooltip */
27 | left?: number
28 |
29 | /** margin to the top of the tooltip */
30 | top?: number
31 |
32 | /** if no custom text function is specified, specifies how to get the x value from a specific data point */
33 | xAccessor?: AccessorFunction
34 |
35 | /** if no custom text function is specified, specifies how to get the y value from a specific data point */
36 | yAccessor?: AccessorFunction
37 | }
38 |
39 | export default class Tooltip {
40 | legendObject = TooltipSymbol.LINE
41 | legend: Array
42 | colors = constants.defaultColors
43 | data: Array
44 | left = 0
45 | top = 0
46 | node: any
47 | textFunction = (x: any) => 'bla'
48 |
49 | constructor({ legendObject, legend, colors, textFunction, data, left, top, xAccessor, yAccessor }: ITooltip) {
50 | this.legendObject = legendObject ?? this.legendObject
51 | this.legend = legend ?? []
52 | this.colors = colors ?? this.colors
53 | this.setTextFunction(textFunction, xAccessor, yAccessor)
54 | this.data = data ?? []
55 | this.left = left ?? this.left
56 | this.top = top ?? this.top
57 | }
58 |
59 | /**
60 | * Sets the text function for the tooltip.
61 | *
62 | * @param textFunction custom text function for the tooltip text. Generated from xAccessor and yAccessor if not
63 | * @param xAccessor if no custom text function is specified, this function specifies how to get the x value from a specific data point.
64 | * @param yAccessor if no custom text function is specified, this function specifies how to get the y value from a specific data point.
65 | */
66 | setTextFunction(textFunction?: TextFunction, xAccessor?: AccessorFunction, yAccessor?: AccessorFunction): void {
67 | this.textFunction =
68 | textFunction || (xAccessor && yAccessor ? this.baseTextFunction(xAccessor, yAccessor) : this.textFunction)
69 | }
70 |
71 | /**
72 | * If no textFunction was specified when creating the tooltip instance, this method generates a text function based on the xAccessor and yAccessor.
73 | *
74 | * @param xAccessor returns the x value of a given data point.
75 | * @param yAccessor returns the y value of a given data point.
76 | * @returns base text function used to render the tooltip for a given datapoint.
77 | */
78 | baseTextFunction(xAccessor: AccessorFunction, yAccessor: AccessorFunction): TextFunction {
79 | return (point: any) => `${xAccessor(point)}: ${yAccessor(point)}`
80 | }
81 |
82 | /**
83 | * Update the tooltip.
84 | */
85 | update({ data, legendObject, legend }: Pick): void {
86 | this.data = data ?? this.data
87 | this.legendObject = legendObject ?? this.legendObject
88 | this.legend = legend ?? this.legend
89 | this.addText()
90 | }
91 |
92 | /**
93 | * Hide the tooltip (without destroying it).
94 | */
95 | hide(): void {
96 | this.node.attr('opacity', 0)
97 | }
98 |
99 | /**
100 | * Mount the tooltip to the given d3 node.
101 | *
102 | * @param svg d3 node to mount the tooltip to.
103 | */
104 | mountTo(svg: GenericD3Selection): void {
105 | this.node = svg
106 | .append('g')
107 | .style('font-size', '0.7rem')
108 | .attr('transform', `translate(${this.left},${this.top})`)
109 | .attr('opacity', 0)
110 | this.addText()
111 | }
112 |
113 | /**
114 | * Adds the text to the tooltip.
115 | * For each datapoint in the data array, one line is added to the tooltip.
116 | */
117 | addText(): void {
118 | // first, clear existing content
119 | this.node.selectAll('*').remove()
120 |
121 | // second, add one line per data entry
122 | this.node.attr('opacity', 1)
123 | this.data.forEach((datum, index) => {
124 | const symbol = constants.symbol[this.legendObject]
125 | const realIndex = datum.arrayIndex ?? index
126 | const color = this.colors[realIndex]
127 | const node = this.node
128 | .append('text')
129 | .attr('text-anchor', 'end')
130 | .attr('y', index * 12)
131 |
132 | // category
133 | node.append('tspan').classed('text-category', true).attr('fill', color).text(this.legend[realIndex])
134 |
135 | // symbol
136 | node.append('tspan').attr('dx', '6').attr('fill', color).text(symbol)
137 |
138 | // text
139 | node
140 | .append('tspan')
141 | .attr('dx', '6')
142 | .text(`${this.textFunction(datum)}`)
143 | })
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/lib/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as LineChart } from './charts/line'
2 | export { default as ScatterChart } from './charts/scatter'
3 | export { default as HistogramChart } from './charts/histogram'
4 |
--------------------------------------------------------------------------------
/lib/src/mg.css:
--------------------------------------------------------------------------------
1 | .mg-graph .domain {
2 | stroke: #b3b2b2;
3 | }
4 |
5 | .mg-graph .tick line {
6 | stroke: #b3b2b2;
7 | }
8 |
9 | .mg-graph .tick text {
10 | font-size: 0.7rem;
11 | fill: black;
12 | opacity: 0.6;
13 | }
14 |
15 | .mg-graph .label {
16 | font-size: 0.8rem;
17 | font-weight: 600;
18 | opacity: 0.8;
19 | }
20 |
21 | .mg-graph .line-marker {
22 | stroke: #ccc;
23 | stroke-width: 1px;
24 | stroke-dasharray: 2;
25 | }
26 |
27 | .mg-graph .line-baseline {
28 | stroke: #ccc;
29 | stroke-width: 1px;
30 | }
31 |
32 | .mg-graph .text-marker {
33 | text-anchor: middle;
34 | font-size: 0.7rem;
35 | fill: black;
36 | }
37 |
38 | .mg-graph .text-baseline {
39 | text-anchor: end;
40 | font-size: 0.7rem;
41 | fill: black;
42 | }
43 |
44 | .mg-graph .text-category {
45 | font-weight: bold;
46 | }
47 |
48 | .mg-line {
49 | stroke-width: 1;
50 | fill: none;
51 | }
52 |
53 | .mg-area {
54 | fill-opacity: 0.3;
55 | }
56 |
57 | .text-legend {
58 | margin-right: 1em;
59 | font-size: 0.7rem;
60 | }
61 |
--------------------------------------------------------------------------------
/lib/src/misc/constants.ts:
--------------------------------------------------------------------------------
1 | const constants = {
2 | chartType: {
3 | line: 'line',
4 | histogram: 'histogram',
5 | bar: 'bar',
6 | point: 'point'
7 | },
8 | axisOrientation: {
9 | top: 'top',
10 | bottom: 'bottom',
11 | left: 'left',
12 | right: 'right'
13 | },
14 | scaleType: {
15 | categorical: 'categorical',
16 | linear: 'linear',
17 | log: 'log'
18 | },
19 | axisFormat: {
20 | date: 'date',
21 | number: 'number',
22 | percentage: 'percentage'
23 | },
24 | legendObject: {
25 | circle: 'circle',
26 | line: 'line',
27 | square: 'square'
28 | },
29 | symbol: {
30 | line: '—',
31 | circle: '•',
32 | square: '■'
33 | },
34 | orientation: {
35 | vertical: 'vertical',
36 | horizontal: 'horizontal'
37 | },
38 | defaultColors: [
39 | '#1f77b4',
40 | '#ff7f0e',
41 | '#2ca02c',
42 | '#d62728',
43 | '#9467bd',
44 | '#8c564b',
45 | '#e377c2',
46 | '#7f7f7f',
47 | '#bcbd22',
48 | '#17becf'
49 | ]
50 | }
51 |
52 | export default constants
53 |
--------------------------------------------------------------------------------
/lib/src/misc/typings.ts:
--------------------------------------------------------------------------------
1 | import { Selection } from 'd3'
2 |
3 | export interface AccessorFunction {
4 | (dataObject: X): Y
5 | }
6 |
7 | export interface TextFunction {
8 | (dataObject: unknown): string
9 | }
10 |
11 | export interface InteractionFunction {
12 | (pointArray: Array): void
13 | }
14 |
15 | export interface EmptyInteractionFunction {
16 | (): void
17 | }
18 |
19 | export interface DefinedFunction {
20 | (dataObject: unknown): boolean
21 | }
22 |
23 | export interface Margin {
24 | left: number
25 | right: number
26 | bottom: number
27 | top: number
28 | }
29 |
30 | export interface DomainObject {
31 | x: Domain
32 | y: Domain
33 | }
34 |
35 | export enum LegendSymbol {
36 | LINE = 'line',
37 | CIRCLE = 'circle',
38 | SQUARE = 'square'
39 | }
40 |
41 | export type BrushType = 'xy' | 'x' | 'y'
42 |
43 | export type Domain = number[]
44 | export type Range = number[]
45 |
46 | export type GenericD3Selection = Selection
47 | export type SvgD3Selection = Selection
48 | export type GD3Selection = Selection
49 | export type LineD3Selection = Selection
50 | export type TextD3Selection = Selection
51 |
--------------------------------------------------------------------------------
/lib/src/misc/utility.ts:
--------------------------------------------------------------------------------
1 | import { AccessorFunction } from './typings'
2 |
3 | /**
4 | * Handle cases where the user specifies an accessor string instead of an accessor function.
5 | *
6 | * @param functionOrString accessor string/function to be made an accessor function
7 | * @returns accessor function
8 | */
9 | export function makeAccessorFunction(functionOrString: AccessorFunction | string): AccessorFunction {
10 | return typeof functionOrString === 'string' ? (d: any) => d[functionOrString] : functionOrString
11 | }
12 |
13 | /**
14 | * Generate a random id.
15 | * Used to create ids for clip paths, which need to be referenced by id.
16 | *
17 | * @returns random id string.
18 | */
19 | export function randomId(): string {
20 | return Math.random().toString(36).substring(2, 15)
21 | }
22 |
--------------------------------------------------------------------------------
/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "lib": ["ES2020", "DOM"],
7 | "strict": true,
8 | "sourceMap": true,
9 | "declaration": true,
10 | "esModuleInterop": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "outDir": "dist"
13 | },
14 | "include": ["src/**/*.ts"]
15 | }
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": [
4 | "lib",
5 | "app"
6 | ],
7 | "repository": "github:metricsgraphics/metrics-graphics",
8 | "contributors": [
9 | "Ali Almossawi",
10 | "Hamilton Ulmer",
11 | "William Lachance",
12 | "Jens Ochsenmeier"
13 | ],
14 | "license": "MPL-2.0",
15 | "bugs": {
16 | "url": "https://github.com/metricsgraphics/metrics-graphics/issues"
17 | },
18 | "homepage": "http://metricsgraphicsjs.org",
19 | "dependencies": {},
20 | "devDependencies": {
21 | "@typescript-eslint/eslint-plugin": "^5.25.0",
22 | "@typescript-eslint/parser": "^5.25.0",
23 | "eslint": "^8.15.0",
24 | "eslint-config-prettier": "^8.5.0",
25 | "eslint-config-standard": "^17.0.0",
26 | "eslint-plugin-import": "^2.26.0",
27 | "eslint-plugin-n": "^15.2.0",
28 | "eslint-plugin-prettier": "^4.0.0",
29 | "eslint-plugin-promise": "^6.0.0",
30 | "eslint-plugin-react": "^7.30.0",
31 | "eslint-plugin-react-hooks": "^4.5.0",
32 | "prettier": "^2.6.2",
33 | "typescript": "^4.6.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------