```
Then, use the id to mount the chart:
```js
import LineChart from 'metrics-graphics'
new LineChart({
data, // some array of data objects
width: 600,
height: 200,
target: '#chart',
area: true,
xAccessor: 'date',
yAccessor: 'value'
})
```
That's it!

The raw data for this example can be found [here](packages/examples/src/assets/data/ufoSightings.js)
## Documentation
If you want to use *MetricsGraphics*, you can find the public API [here](packages/lib/docs/API.md).
If you want to extend *MetricsGraphics*, you can read up on the [components](packages/lib/docs/Components.md) and [utilities](packages/lib/docs/Utility.md).
## Development Setup
This project uses [Yarn Workspaces](https://classic.yarnpkg.com/lang/en/docs/workspaces/). Please make sure that Yarn is installed.
```bash
# clone and setup
git clone https://github.com/metricsgraphics/metrics-graphics
cd metrics-graphics
yarn install
```
Run both the development setup of the library and the development setup of the examples
```bash
# inside packages/lib
yarn dev
# inside packages/examples
yarn dev
```
================================================
FILE: app/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
================================================
FILE: app/README.md
================================================
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
================================================
FILE: app/components/Layout.tsx
================================================
import Head from 'next/head'
import { PropsWithChildren } from 'react'
interface LayoutProps {
title: string
}
const Layout: React.FC> = ({ title, children }) => (
<>
{title}
{children}
>
)
export default Layout
================================================
FILE: app/components/Logo.tsx
================================================
const Logo = () => (
)
export default Logo
================================================
FILE: app/components/NavLink.tsx
================================================
import Link, { LinkProps } from 'next/link'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import cx from 'classnames'
const NavLink: React.FC> = ({ href, children, ...linkProps }) => {
const router = useRouter()
return (
{children}
)
}
export default NavLink
================================================
FILE: app/components/ParameterTable.tsx
================================================
interface ParameterTableProps {
props: Array<{
name: string
type: string
default?: string
description: string
}>
}
const ParameterTable: React.FC = ({ props }) => (
Name
Type
Default
Description
{props.map((p) => (
{p.name}
{p.type}
{p.default ? {p.default} : '-'}
{p.description}
))}
)
export default ParameterTable
================================================
FILE: app/components/charts/Renderer.tsx
================================================
import { MutableRefObject, PropsWithChildren, useEffect, useRef } from 'react'
interface RendererProps {
chartRenderer: (chartRef: MutableRefObject) => unknown
}
const Renderer: React.FC> = ({ chartRenderer, children }) => {
const chartRef = useRef(null)
// render chart
useEffect(() => {
// if react is still rendering, wait
if (!chartRef.current) return
// call render function with ref
chartRenderer(chartRef.current)
})
return (
)
}
export default MyApp
================================================
FILE: app/pages/_document.tsx
================================================
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
)
}
}
export default MyDocument
================================================
FILE: app/pages/histogram.mdx
================================================
import Layout from '../components/Layout'
import ParameterTable from '../components/ParameterTable'
import Simple from '../components/charts/histogram/Simple'
# Histograms
## API
Extends the [base chart options](./mg-api). All options below are optional.
## Examples
### Difference in UFO Sighting and Reporting Dates (in months)
Semi-real data about the reported differences between the supposed sighting of a UFO and the date it was reported.
```js
new HistogramChart({
data: ufoData.map((date) => date / 30).sort(),
width: 600,
height: 200,
binCount: 150,
target: '#my-div',
brush: 'x',
yAxis: {
extendedTicks: true
},
tooltipFunction: (bar) => `${bar.time} months, volume ${bar.count}`
})
```
================================================
FILE: app/pages/index.tsx
================================================
import type { NextPage } from 'next'
import Head from 'next/head'
import { useEffect, useRef } from 'react'
import { LineChart } from 'metrics-graphics'
import sightings from '../data/ufoSightings.json'
const Home: NextPage = () => {
const chartRef = useRef(null)
useEffect(() => {
if (!chartRef.current) return
const lineChart = new LineChart({
data: [sightings],
markers: [{ year: 1964, label: '"The Creeping Terror" released' }],
width: 650,
height: 180,
target: chartRef.current as any,
xAccessor: 'year',
yAccessor: 'sightings',
area: true,
yScale: {
minValue: 0
},
xAxis: {
extendedTicks: true,
label: 'Year',
tickFormat: '.4r'
},
yAxis: {
label: 'Count'
}
})
}, [chartRef])
return (
MetricsGraphics
MetricsGraphics is a library built on top of D3 that is optimized for visualizing and laying out time-series
data. It provides a simple way to produce common types of graphics in a principled, consistent and responsive
way.
)
}
export default Home
================================================
FILE: app/pages/line.mdx
================================================
import ParameterTable from '../components/ParameterTable'
import Layout from '../components/Layout'
import Simple from '../components/charts/line/Simple'
import Confidence from '../components/charts/line/Confidence'
import Multi from '../components/charts/line/Multi'
import Aggregated from '../components/charts/line/Aggregated'
import Broken from '../components/charts/line/Broken'
import Active from '../components/charts/line/Active'
import Baseline from '../components/charts/line/Baseline'
# Line Charts
## API
Extends the [base chart options](./mg-api). All options below are optional.
| boolean',
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.'
}, {
name: 'confidenceBand',
type: '[Accessor, Accessor]',
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).'
}, {
name: 'voronoi',
type: 'Partial',
description: 'Custom parameters passed to the voronoi generator.'
}, {
name: 'defined',
type: '(point: Data) => boolean',
description: 'Function specifying whether or not to show a given datapoint. This is mainly used to create partially defined graphs.'
}, {
name: 'activeAccessor',
type: 'Accessor',
description: 'Accessor that defines whether or not a given data point should be shown as active'
}, {
name: 'activePoint',
type: 'Partial',
description: 'Custom parameters passed to the active point generator.'
}]} />
## Examples
### Simple Line Chart
This is a simple line chart. You can remove the area portion by adding `area: false` to the arguments list.
```js
new LineChart({
data: [fakeUsers.map(({ date, value }) => ({ date: new Date(date), value }))],
width: 600,
height: 200,
yScale: {
minValue: 0
},
target: '#my-div',
brush: 'xy',
area: true,
xAccessor: 'date',
yAccessor: 'value',
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
})
```
### Confidence Band
This is an example of a graph with a confidence band and extended x-axis ticks enabled.
```js
new LineChart({
data: [
confidence.map((entry) => ({
...entry,
date: new Date(entry.date)
}))
],
xAxis: {
extendedTicks: true
},
yAxis: {
tickFormat: 'percentage'
},
width: 600,
height: 200,
target: '#my-div',
confidenceBand: ['l', 'u'],
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatPercent(point.value)}`
})
```
### Multiple Lines
This line chart contains multiple lines.
```js
new LineChart({
data: fakeUsers.map((fakeArray) =>
fakeArray.map((fakeEntry) => ({
...fakeEntry,
date: new Date(fakeEntry.date)
}))
),
width: 600,
height: 200,
target: '#my-div',
xAccessor: 'date',
yAccessor: 'value',
legend: ['Line 1', 'Line 2', 'Line 3'],
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
})
```
### Aggregate Rollover
One rollover for all lines.
```js
new LineChart({
data: fakeUsers.map((fakeArray) =>
fakeArray.map((fakeEntry) => ({
...fakeEntry,
date: new Date(fakeEntry.date)
}))
),
width: 600,
height: 200,
target: '#my-div',
xAccessor: 'date',
yAccessor: 'value',
legend: ['Line 1', 'Line 2', 'Line 3'],
voronoi: {
aggregate: true
},
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
})
```
### Broken lines (missing data points)
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.
```js
new LineChart({
data: [missing.map((e) => ({ ...e, date: new Date(e.date) }))],
width: 600,
height: 200,
target: '#my-div',
defined: (d) => !d.dead,
area: true,
tooltipFunction: (point) => `${formatDate(point.date)}: ${point.value}`
})
```
### Active Points
This line chart displays pre-defined active points.
```js
new LineChart({
data: [
fakeUsers.map((entry, i) => ({
...entry,
date: new Date(entry.date),
active: i % 5 === 0
}))
],
width: 600,
height: 200,
target: '#my-div',
activeAccessor: 'active',
activePoint: {
radius: 2
},
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
})
```
### Baseline
Baselines are horizontal lines that can added at arbitrary points.
```js
new LineChart({
data: [
fakeUsers.map((entry) => ({
...entry,
date: new Date(entry.date)
}))
],
baselines: [{ value: 160000000, label: 'a baseline' }],
width: 600,
height: 200,
target: '#my-div',
tooltipFunction: (point) => `${formatDate(point.date)}: ${formatCompact(point.value)}`
})
```
================================================
FILE: app/pages/mg-api.mdx
================================================
import ParameterTable from '../components/ParameterTable'
import Layout from '../components/Layout'
# API
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`.
## Data formats
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:
```js
[{
date: '2020-02-01',
value: 10
}, {
date: '2020-02-02',
value: 12
}]
```
## Common Parameters
All charts inherit from an abstract chart, which has the following parameters (optional parameters marked with `?`):
',
description: 'Data that is to be visualized.'
}, {
name: 'target',
type: 'string',
description: 'DOM node to which the graph will be mounted (compatible D3 selection or D3 selection specifier).'
}, {
name: 'width',
type: 'number',
description: 'Total width of the graph.'
}, {
name: 'height',
type: 'number',
description: 'Total height of the graph.'
}, {
name: 'markers?',
type: 'Array',
description: 'Markers that should be added to the chart. Each marker object should be accessible through the xAccessor and contain a label field.'
}, {
name: 'baselines?',
type: 'Array',
description: 'Baselines that should be added to the chart. Each baseline object should be accessible through the yAccessor and contain a label field.'
}, {
name: 'xAccessor?',
type: 'string | Accessor',
default: 'date',
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.'
}, {
name: 'yAccessor?',
type: 'string | Accessor',
default: 'value',
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.'
}, {
name: 'margin?',
type: 'Margin',
default: 'top: 10, left: 60, right: 20, bottom: 40',
description: 'Margin around the chart for labels.'
}, {
name: 'buffer?',
type: 'number',
default: '10',
description: 'Amount of buffer space between the axes and the actual graph.'
}, {
name: 'colors?',
type: 'Array',
default: 'd3.schemeCategory10',
description: 'Custom color scheme for the graph.'
}, {
name: 'xScale?',
type: 'Partial',
description: 'Overwrite parameters of the auto-generated x scale.'
}, {
name: 'yScale?',
type: 'Partial',
description: 'Overwrite parameters of the auto-generated y scale.'
}, {
name: 'xAxis?',
type: 'Partial',
description: 'Overwrite parameters of the auto-generated x axis.'
}, {
name: 'yAxis?',
type: 'Partial',
description: 'Overwrite parameters of the auto-generated y axis.'
}, {
name: 'showTooltip?',
type: 'boolean',
default: 'true',
description: 'Whether or not to show a tooltip.'
}, {
name: 'tooltipFunction',
type: 'Accessor',
description: 'Generate a custom tooltip string.'
}, {
name: 'legend?',
type: 'Array',
description: 'Used if data is an array of arrays. Names of the sub-arrays of data, used as legend labels.'
}, {
name: 'brush?',
type: '"xy" | "x" | "y"',
description: 'Adds either a one- or two-dimensional brush to the chart.'
}]} />
## Common Types
```ts
type Accessor = (dataObject: X) => Y
type Margin = {
left: number
right: number
bottom: number
top: number
}
type Scale = {
type: 'linear' // this will be extended in the future
range?: [number, number]
domain?: [number, number]
}
type Axis = {
scale: Scale
buffer: number
show?: boolean
orientation?: 'top' | 'bottom' | 'left' | 'right'
label?: string
labelOffset?: number
top?: number
left?: number
// a function to format a given tick, or one of the standard types (date, number, percentage), or string for d3-format
tickFormat?: TextFunction | AxisFormat | string
// defaults to 3 for vertical and 6 for horizontal axes
tickCount?: number
compact?: boolean
// tick label prefix
prefix?: string
// tick label suffix
suffix?: string
// overwrite d3's default tick lengths
tickLength?: number
// draw extended tick lines
extendedTicks?: boolean
}
```
================================================
FILE: app/pages/scatter.mdx
================================================
import Layout from '../components/Layout'
import ParameterTable from '../components/ParameterTable'
import Simple from '../components/charts/scatter/Simple'
import Categories from '../components/charts/scatter/Categories'
import Complex from '../components/charts/scatter/Complex'
# Scatterplots
## API
Extends the [base chart options](./mg-api). All options below are optional.
3'
}, {
name: 'xRug',
type: 'boolean',
description: 'Whether or not to generate a rug for the x axis.'
}, {
name: 'yRug',
type: 'boolean',
description: 'Whether or not to generate a rug for the y axis.'
}]} />
## Examples
### Simple Scatterplot
This is an example scatterplot, in which we have enabled rug plots on the y-axis by setting the rug option to `true`.
```js
new ScatterChart({
data: [points1],
width: 500,
height: 200,
target: '#my-div',
xAccessor: 'x',
yAccessor: 'y',
brush: 'xy',
xRug: true,
tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}`
})
```
### Multi-Category Scatterplot
This scatterplot contains data of multiple categories.
```js
new ScatterChart({
data: points2.map((x: any) => x.values),
legend: points2.map((x: any) => x.key),
width: 500,
height: 200,
xAccessor: 'x',
yAccessor: 'y',
yRug: true,
target: '#my-div',
tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}`
})
```
### Scatterplot with Size and Color
Scatterplots have xAccessor, yAccessor and sizeAccessor.
```js
new ScatterChart({
data: points2.map((x: any) => x.values),
legend: points2.map((x: any) => x.key),
width: 500,
height: 200,
target: '#my-div',
xAccessor: 'x',
yAccessor: 'y',
sizeAccessor: (x: any) => Math.abs(x.w) * 3,
tooltipFunction: (point) => `${formatDecimal(point.x)} - ${formatDecimal(point.y)}: ${formatDecimal(point.w)}`
})
```
================================================
FILE: app/postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
================================================
FILE: app/styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
.token.prolog,
.token.doctype,
.token.cdata {
@apply text-gray-700;
}
.token.comment {
@apply text-gray-500;
}
.token.punctuation {
@apply text-gray-700;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
@apply text-green-500;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
@apply text-purple-500;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
@apply text-yellow-500;
}
.token.atrule,
.token.attr-value,
.token.keyword {
@apply text-blue-500;
}
.token.function,
.token.class-name {
@apply text-pink-500;
}
.token.regex,
.token.important,
.token.variable {
@apply text-yellow-500;
}
code[class*='language-'],
pre[class*='language-'] {
@apply text-gray-800;
}
pre::-webkit-scrollbar {
display: none;
}
pre {
@apply bg-gray-50;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
================================================
FILE: app/tailwind.config.js
================================================
// eslint-disable-next-line @typescript-eslint/no-var-requires
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
typography: ({ theme }) => ({
DEFAULT: {
css: {
pre: {
backgroundColor: theme('colors.indigo[50]'),
fontSize: '0.7rem'
}
}
}
}),
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans]
}
}
},
plugins: [require('@tailwindcss/typography')]
}
================================================
FILE: app/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: lib/.gitignore
================================================
# Dependencies
bower_components
node_modules
# Logs
npm-debug.log
# IDE
.idea
# FS
.DS_Store
# Others
other/divider.psd
other/htaccess.txt
bare.html
yarn.lock
yarn-error.log
# Dist files
dist
build
================================================
FILE: lib/esbuild.mjs
================================================
import esbuild from 'esbuild'
const baseConfig = {
entryPoints: ['src/index.ts'],
bundle: true,
sourcemap: true,
target: 'esnext'
}
// esm
esbuild.build({
...baseConfig,
outdir: 'dist/esm',
splitting: true,
format: 'esm'
})
// cjs
esbuild.build({
...baseConfig,
outdir: 'dist/cjs',
format: 'cjs'
})
================================================
FILE: lib/package.json
================================================
{
"name": "metrics-graphics",
"version": "3.0.1",
"description": "A library optimized for concise, principled data graphics and layouts",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"scripts": {
"ts-types": "tsc --emitDeclarationOnly --outDir dist",
"build": "rimraf dist && concurrently \"node ./esbuild.mjs\" \"npm run ts-types\" && cp src/mg.css dist/mg.css",
"lint": "eslint src",
"test": "echo \"no tests set up, will do later\"",
"analyze": "source-map-explorer dist/esm/index.js"
},
"repository": {
"type": "git",
"url": "git://github.com/metricsgraphics/metrics-graphics.git"
},
"files": [
"dist"
],
"keywords": [
"metrics-graphics",
"metricsgraphicsjs",
"metricsgraphics",
"metricsgraphics.js",
"d3 charts"
],
"author": "Mozilla",
"contributors": [
"Ali Almossawi",
"Hamilton Ulmer",
"William Lachance",
"Jens Ochsenmeier"
],
"license": "MPL-2.0",
"bugs": {
"url": "https://github.com/metricsgraphics/metrics-graphics/issues"
},
"engines": {
"node": ">=0.8.0"
},
"homepage": "http://metricsgraphicsjs.org",
"dependencies": {
"d3": "^7.4.4"
},
"devDependencies": {
"@types/d3": "^7.1.0",
"concurrently": "^7.2.0",
"deepmerge": "^4.2.2",
"esbuild": "^0.14.39",
"rimraf": "^3.0.2",
"source-map-explorer": "^2.5.2"
}
}
================================================
FILE: lib/src/charts/abstractChart.ts
================================================
import { select, extent, max, brush as d3brush, brushX, brushY } from 'd3'
import { randomId, makeAccessorFunction } from '../misc/utility'
import Scale from '../components/scale'
import Axis, { IAxis, AxisOrientation } from '../components/axis'
import Tooltip from '../components/tooltip'
import Legend from '../components/legend'
import constants from '../misc/constants'
import Point, { IPoint } from '../components/point'
import {
SvgD3Selection,
AccessorFunction,
Margin,
GenericD3Selection,
BrushType,
DomainObject,
Domain,
LegendSymbol
} from '../misc/typings'
type TooltipFunction = (datapoint: any) => string
export interface IAbstractChart {
/** data that is to be visualized */
data: Array
/** DOM node to which the graph will be mounted (D3 selection or D3 selection specifier) */
target: string
/** total width of the graph */
width: number
/** total height of the graph */
height: number
/** markers that should be added to the chart. Each marker object should be accessible through the xAccessor and contain a label field */
markers?: Array
/** baselines that should be added to the chart. Each baseline object should be accessible through the yAccessor and contain a label field */
baselines?: Array
/** either name of the field that contains the x value or function that receives a data object and returns its x value */
xAccessor?: string | AccessorFunction
/** either name of the field that contains the y value or function that receives a data object and returns its y value */
yAccessor?: string | AccessorFunction
/** margins of the visualization for labels */
margin?: Margin
/** amount of buffer between the axes and the graph */
buffer?: number
/** custom color scheme for the graph */
colors?: Array
/** overwrite parameters of the auto-generated x scale */
xScale?: Partial
/** overwrite parameters of the auto-generated y scale */
yScale?: Partial
/** overwrite parameters of the auto-generated x axis */
xAxis?: Partial
/** overwrite parameters of the auto-generated y axis */
yAxis?: Partial
/** whether or not to show a tooltip */
showTooltip?: boolean
/** generate a custom tooltip string */
tooltipFunction?: (datapoint: any) => string
/** names of the sub-arrays of data, used as legend labels */
legend?: Array
/** add an optional brush */
brush?: BrushType
/** custom domain computations */
computeDomains?: () => DomainObject
}
/**
* This abstract chart class implements all functionality that is shared between all available chart types.
*/
export default abstract class AbstractChart {
id: string
// base chart fields
data: Array
markers: Array
baselines: Array
target: SvgD3Selection
svg?: GenericD3Selection
content?: GenericD3Selection
container?: GenericD3Selection
// accessors
xAccessor: AccessorFunction
yAccessor: AccessorFunction
colors: Array
// scales
xDomain: Domain
yDomain: Domain
xScale: Scale
yScale: Scale
// axes
xAxis?: Axis
xAxisParams: any
yAxis?: Axis
yAxisParams: any
// tooltip and legend stuff
showTooltip: boolean
tooltipFunction?: TooltipFunction
tooltip?: Tooltip
legend?: Array
// dimensions
width: number
height: number
// margins
margin: Margin
buffer: number
// brush
brush?: BrushType
idleDelay = 350
idleTimeout: unknown
constructor({
data,
target,
markers,
baselines,
xAccessor,
yAccessor,
margin,
buffer,
width,
height,
colors,
xScale,
yScale,
xAxis,
yAxis,
showTooltip,
tooltipFunction,
legend,
brush,
computeDomains
}: IAbstractChart) {
// convert string accessors to functions if necessary
this.xAccessor = makeAccessorFunction(xAccessor ?? 'date')
this.yAccessor = makeAccessorFunction(yAccessor ?? 'value')
// set parameters
this.data = data
this.target = select(target)
this.markers = markers ?? []
this.baselines = baselines ?? []
this.legend = legend ?? this.legend
this.brush = brush ?? undefined
this.xAxisParams = xAxis ?? this.xAxisParams
this.yAxisParams = yAxis ?? this.yAxisParams
this.showTooltip = showTooltip ?? true
this.tooltipFunction = tooltipFunction
this.margin = margin ?? { top: 10, left: 60, right: 20, bottom: 40 }
this.buffer = buffer ?? 10
// set unique id for chart
this.id = randomId()
// compute dimensions
this.width = width
this.height = height
// normalize color and colors arguments
this.colors = colors ?? constants.defaultColors
// clear target
this.target.selectAll('*').remove()
// attach base elements to svg
this.mountSvg()
// set up scales
this.xScale = new Scale({ range: [0, this.innerWidth], ...xScale })
this.yScale = new Scale({ range: [this.innerHeight, 0], ...yScale })
// compute domains and set them
const { x, y } = computeDomains ? computeDomains() : this.computeDomains()
this.xDomain = x
this.yDomain = y
this.xScale.domain = x
this.yScale.domain = y
this.abstractRedraw()
}
/**
* Draw the abstract chart.
*/
abstractRedraw(): void {
// if not drawn yet, abort
if (!this.content) return
// clear
this.content.selectAll('*').remove()
// set up axes if not disabled
this.mountXAxis(this.xAxisParams)
this.mountYAxis(this.yAxisParams)
// pre-attach tooltip text container
this.mountTooltip(this.showTooltip, this.tooltipFunction)
// set up main container
this.mountContainer()
}
/**
* Draw the actual chart.
* This is meant to be overridden by chart implementations.
*/
abstract redraw(): void
mountBrush(whichBrush?: BrushType): void {
// if no brush is specified, there's nothing to mount
if (!whichBrush) return
// brush can only be mounted after content is set
if (!this.content || !this.container) {
console.error('error: content not set yet')
return
}
const brush = whichBrush === 'x' ? brushX() : whichBrush === 'y' ? brushY() : d3brush()
brush.on('end', ({ selection }) => {
// if no content is set, do nothing
if (!this.content) {
console.error('error: content is not set yet')
return
}
// compute domains and re-draw
if (selection === null) {
if (!this.idleTimeout) {
this.idleTimeout = setTimeout(() => {
this.idleTimeout = null
}, 350)
return
}
// set original domains
this.xScale.domain = this.xDomain
this.yScale.domain = this.yDomain
} else {
if (this.brush === 'x') {
this.xScale.domain = [selection[0], selection[1]].map(this.xScale.scaleObject.invert)
} else if (this.brush === 'y') {
this.yScale.domain = [selection[0], selection[1]].map(this.yScale.scaleObject.invert)
} else {
this.xScale.domain = [selection[0][0], selection[1][0]].map(this.xScale.scaleObject.invert)
this.yScale.domain = [selection[1][1], selection[0][1]].map(this.yScale.scaleObject.invert)
}
this.content.select('.brush').call((brush as any).move, null)
}
// re-draw abstract elements
this.abstractRedraw()
// re-draw specific chart
this.redraw()
})
this.container.append('g').classed('brush', true).call(brush)
}
/**
* Mount a new legend if necessary
* @param {String} symbolType symbol type (circle, square, line)
*/
mountLegend(symbolType: LegendSymbol): void {
if (!this.legend || !this.legend.length) return
const legend = new Legend({
legend: this.legend,
colorScheme: this.colors,
symbolType
})
legend.mountTo(this.target)
}
/**
* Mount new x axis.
*
* @param xAxis object that can be used to overwrite parameters of the auto-generated x {@link Axis}.
*/
mountXAxis(xAxis: Partial): void {
// axis only mountable after content is mounted
if (!this.content) {
console.error('error: content needs to be mounted first')
return
}
if (typeof xAxis?.show !== 'undefined' && !xAxis.show) return
this.xAxis = new Axis({
scale: this.xScale,
orientation: AxisOrientation.BOTTOM,
top: this.bottom,
left: this.left,
height: this.innerHeight,
buffer: this.buffer,
...xAxis
})
if (!xAxis?.tickFormat) this.computeXAxisType()
// attach axis
if (this.xAxis) this.xAxis.mountTo(this.content)
}
/**
* Mount new y axis.
*
* @param yAxis object that can be used to overwrite parameters of the auto-generated y {@link Axis}.
*/
mountYAxis(yAxis: Partial): void {
// axis only mountable after content is mounted
if (!this.content) {
console.error('error: content needs to be mounted first')
return
}
if (typeof yAxis?.show !== 'undefined' && !yAxis.show) return
this.yAxis = new Axis({
scale: this.yScale,
orientation: AxisOrientation.LEFT,
top: this.top,
left: this.left,
height: this.innerWidth,
buffer: this.buffer,
...yAxis
})
if (!yAxis?.tickFormat) this.computeYAxisType()
if (this.yAxis) this.yAxis.mountTo(this.content)
}
/**
* Mount a new tooltip if necessary.
*
* @param showTooltip whether or not to show a tooltip.
* @param tooltipFunction function that receives a data object and returns the string displayed as tooltip.
*/
mountTooltip(showTooltip?: boolean, tooltipFunction?: TooltipFunction): void {
// only mount of content is defined
if (!this.content) {
console.error('error: content is not defined yet')
return
}
if (typeof showTooltip !== 'undefined' && !showTooltip) return
this.tooltip = new Tooltip({
top: this.buffer,
left: this.width - 2 * this.buffer,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
textFunction: tooltipFunction,
colors: this.colors,
legend: this.legend
})
this.tooltip.mountTo(this.content)
}
/**
* Mount the main container.
*/
mountContainer(): void {
// content needs to be mounted first
if (!this.content) {
console.error('content needs to be mounted first')
return
}
const width = max(this.xScale.range)
const height = max(this.yScale.range)
if (!width || !height) {
console.error(`error: width or height is null (width: "${width}", height: "${height}")`)
return
}
this.container = this.content
.append('g')
.attr('transform', `translate(${this.left},${this.top})`)
.attr('clip-path', `url(#mg-plot-window-${this.id})`)
.append('g')
.attr('transform', `translate(${this.buffer},${this.buffer})`)
this.container
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('opacity', 0)
.attr('pointer-events', 'all')
.attr('width', width)
.attr('height', height)
}
/**
* This method is called by the abstract chart constructor.
* Append the local svg node to the specified target, if necessary.
* Return existing svg node if it's already present.
*/
mountSvg(): void {
const svg = this.target.select('svg')
// warn user if svg is not empty
if (!svg.empty()) {
console.warn('Warning: SVG is not empty. Rendering might be unnecessary.')
}
// clear svg
svg.remove()
this.svg = this.target.append('svg').classed('mg-graph', true).attr('width', this.width).attr('height', this.height)
// prepare clip path
this.svg.select('.mg-clip-path').remove()
this.svg
.append('defs')
.attr('class', 'mg-clip-path')
.append('clipPath')
.attr('id', `mg-plot-window-${this.id}`)
.append('svg:rect')
.attr('width', this.width - this.margin.left - this.margin.right)
.attr('height', this.height - this.margin.top - this.margin.bottom)
// set viewbox
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`)
// append content
this.content = this.svg.append('g').classed('mg-content', true)
}
/**
* If needed, charts can implement data normalizations, which are applied when instantiating a new chart.
* TODO this is currently unused
*/
// abstract normalizeData(): void
/**
* 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.
* @returns domains for x and y axis as separate properties.
*/
computeDomains(): DomainObject {
const flatData = this.data.flat()
const x = extent(flatData, this.xAccessor)
const y = extent(flatData, this.yAccessor)
return { x: x as [number, number], y: y as [number, number] }
}
/**
* Set tick format of the x axis.
*/
computeXAxisType(): void {
// abort if no x axis is used
if (!this.xAxis) {
console.error('error: no x axis set')
return
}
const flatData = this.data.flat()
const xValue = this.xAccessor(flatData[0])
if (xValue instanceof Date) {
this.xAxis.tickFormat = 'date'
} else if (Number(xValue) === xValue) {
this.xAxis.tickFormat = 'number'
}
}
/**
* Set tick format of the y axis.
*/
computeYAxisType(): void {
// abort if no y axis is used
if (!this.yAxis) {
console.error('error: no y axis set')
return
}
const flatData = this.data.flat()
const yValue = this.yAccessor(flatData[0])
if (yValue instanceof Date) {
this.yAxis.tickFormat = constants.axisFormat.date
} else if (Number(yValue) === yValue) {
this.yAxis.tickFormat = constants.axisFormat.number
}
}
generatePoint(args: Partial): Point {
return new Point({
...args,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
xScale: this.xScale,
yScale: this.yScale
})
}
get top(): number {
return this.margin.top
}
get left(): number {
return this.margin.left
}
get bottom(): number {
return this.height - this.margin.bottom
}
// returns the pixel location of the respective side of the plot area.
get plotTop(): number {
return this.top + this.buffer
}
get plotLeft(): number {
return this.left + this.buffer
}
get innerWidth(): number {
return this.width - this.margin.left - this.margin.right - 2 * this.buffer
}
get innerHeight(): number {
return this.height - this.margin.top - this.margin.bottom - 2 * this.buffer
}
}
================================================
FILE: lib/src/charts/histogram.ts
================================================
import { max, bin } from 'd3'
import Delaunay from '../components/delaunay'
import Rect from '../components/rect'
import { TooltipSymbol } from '../components/tooltip'
import { LegendSymbol, InteractionFunction } from '../misc/typings'
import AbstractChart, { IAbstractChart } from './abstractChart'
interface IHistogramChart extends IAbstractChart {
binCount?: number
}
/**
* Creates a new histogram graph.
*
* @param {Object} args argument object. See {@link AbstractChart} for general parameters.
* @param {Number} [args.binCount] approximate number of bins that should be used for the histogram. Defaults to what d3.bin thinks is best.
*/
export default class HistogramChart extends AbstractChart {
bins: Array
rects?: Array
delaunay?: any
delaunayBar?: any
_activeBar = -1
constructor({ binCount, ...args }: IHistogramChart) {
super({
...args,
computeDomains: () => {
// set up histogram
const dataBin = bin()
if (binCount) dataBin.thresholds(binCount)
const bins = dataBin(args.data)
// update domains
return {
x: [0, bins.length],
y: [0, max(bins, (bin: Array) => +bin.length)!]
}
}
})
// set up histogram
const dataBin = bin()
if (binCount) dataBin.thresholds(binCount)
this.bins = dataBin(this.data)
this.redraw()
}
redraw(): void {
// set up histogram rects
this.mountRects()
// set tooltip type
if (this.tooltip) {
this.tooltip.update({ legendObject: TooltipSymbol.SQUARE })
this.tooltip.hide()
}
// generate delaunator
this.mountDelaunay()
// mount legend if any
this.mountLegend(LegendSymbol.SQUARE)
// mount brush if necessary
this.mountBrush(this.brush)
}
/**
* Mount the histogram rectangles.
*/
mountRects(): void {
this.rects = this.bins.map((bin) => {
const rect = new Rect({
data: bin,
xScale: this.xScale,
yScale: this.yScale,
color: this.colors[0],
fillOpacity: 0.5,
strokeWidth: 0,
xAccessor: (bin) => bin.x0,
yAccessor: (bin) => bin.length,
widthAccessor: (bin) => this.xScale.scaleObject(bin.x1)! - this.xScale.scaleObject(bin.x0)!,
heightAccessor: (bin) => -bin.length
})
rect.mountTo(this.container!)
return rect
})
}
/**
* Handle move events from the delaunay triangulation.
*
* @returns handler function.
*/
onPointHandler(): InteractionFunction {
return ([point]) => {
this.activeBar = point.index
// set tooltip if necessary
if (!this.tooltip) return
this.tooltip.update({ data: [point] })
}
}
/**
* Handle leaving the delaunay triangulation area.
*
* @returns handler function.
*/
onLeaveHandler() {
return () => {
this.activeBar = -1
if (this.tooltip) this.tooltip.hide()
}
}
/**
* Mount new delaunay triangulation.
*/
mountDelaunay(): void {
this.delaunayBar = new Rect({
xScale: this.xScale,
yScale: this.yScale,
xAccessor: (bin) => bin.x0,
yAccessor: (bin) => bin.length,
widthAccessor: (bin) => bin.x1 - bin.x0,
heightAccessor: (bin) => -bin.length
})
this.delaunay = new Delaunay({
points: this.bins.map((bin) => ({
x: (bin.x1 + bin.x0) / 2,
y: 0,
time: bin.x0,
count: bin.length
})),
xAccessor: (d) => d.x,
yAccessor: (d) => d.y,
xScale: this.xScale,
yScale: this.yScale,
onPoint: this.onPointHandler(),
onLeave: this.onLeaveHandler()
})
this.delaunay.mountTo(this.container)
}
get activeBar() {
return this._activeBar
}
set activeBar(i: number) {
// if rexts are not set yet, abort
if (!this.rects) {
console.error('error: can not set active bar, rects are empty')
return
}
// if a bar was previously set, de-set it
if (this._activeBar !== -1) {
this.rects[this._activeBar].update({ fillOpacity: 0.5 })
}
// set state
this._activeBar = i
// set point to active
if (i !== -1) this.rects[i].update({ fillOpacity: 1 })
}
}
================================================
FILE: lib/src/charts/line.ts
================================================
import Line from '../components/line'
import Area from '../components/area'
import constants from '../misc/constants'
import Delaunay, { IDelaunay } from '../components/delaunay'
import { makeAccessorFunction } from '../misc/utility'
import { AccessorFunction, LegendSymbol, InteractionFunction, EmptyInteractionFunction } from '../misc/typings'
import { IPoint } from '../components/point'
import { TooltipSymbol } from '../components/tooltip'
import AbstractChart, { IAbstractChart } from './abstractChart'
type ConfidenceBand = [AccessorFunction | string, AccessorFunction | string]
interface ILineChart extends IAbstractChart {
/** specifies for which sub-array of data an area should be shown. Boolean if data is a simple array */
area?: Array | boolean
/** 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 */
confidenceBand?: ConfidenceBand
/** custom parameters passed to the voronoi generator */
voronoi?: Partial
/** function specifying whether or not to show a given datapoint */
defined?: (point: any) => boolean
/** accessor specifying for a given data point whether or not to show it as active */
activeAccessor?: AccessorFunction | string
/** custom parameters passed to the active point generator. See {@see Point} for a list of parameters */
activePoint?: Partial
}
export default class LineChart extends AbstractChart {
delaunay?: Delaunay
defined?: (point: any) => boolean
activeAccessor?: AccessorFunction
activePoint?: Partial
area?: Array | boolean
confidenceBand?: ConfidenceBand
delaunayParams?: Partial
// one delaunay point per line
delaunayPoints: Array = []
constructor({ area, confidenceBand, voronoi, defined, activeAccessor, activePoint, ...args }: ILineChart) {
super(args)
// if data is not a 2d array, die
if (!Array.isArray(args.data[0])) throw new Error('data is not a 2-dimensional array.')
if (defined) this.defined = defined
if (activeAccessor) this.activeAccessor = makeAccessorFunction(activeAccessor)
this.activePoint = activePoint ?? this.activePoint
this.area = area ?? this.area
this.confidenceBand = confidenceBand ?? this.confidenceBand
this.delaunayParams = voronoi ?? this.delaunayParams
this.redraw()
}
redraw(): void {
this.mountLines()
this.mountActivePoints(this.activePoint ?? {})
// generate areas if necessary
this.mountAreas(this.area || false)
// set tooltip type
if (this.tooltip) {
this.tooltip.update({ legendObject: TooltipSymbol.LINE })
this.tooltip.hide()
}
// generate confidence band if necessary
if (this.confidenceBand) {
this.mountConfidenceBand(this.confidenceBand)
}
// add markers and baselines
this.mountMarkers()
this.mountBaselines()
// set up delaunay triangulation
this.mountDelaunay(this.delaunayParams ?? {})
// mount legend if any
this.mountLegend(LegendSymbol.LINE)
// mount brush if necessary
this.mountBrush(this.brush)
}
/**
* Mount lines for each array of data points.
*/
mountLines(): void {
// abort if container is not defined yet
if (!this.container) {
console.error('error: container is not defined yet')
return
}
// compute lines and delaunay points
this.data.forEach((lineData, index) => {
const line = new Line({
data: lineData,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
xScale: this.xScale,
yScale: this.yScale,
color: this.colors[index],
defined: this.defined
})
this.delaunayPoints[index] = this.generatePoint({ radius: 3 })
line.mountTo(this.container!)
})
}
/**
* If an active accessor is specified, mount active points.
* @param params custom parameters for point generation. See {@see Point} for a list of options.
*/
mountActivePoints(params: Partial): void {
// abort if container is not defined yet
if (!this.container) {
console.error('error: container is not defined yet')
return
}
if (!this.activeAccessor) return
this.data.forEach((pointArray, index) => {
pointArray.filter(this.activeAccessor).forEach((data: any) => {
const point = this.generatePoint({
data,
color: this.colors[index],
radius: 3,
...params
})
point.mountTo(this.container!)
})
})
}
/**
* Mount all specified areas.
*
* @param area specifies for which sub-array of data an area should be shown. Boolean if data is a simple array.
*/
mountAreas(area: Array | boolean): void {
if (typeof area === 'undefined') return
let areas: Array = []
const areaGenerator = (lineData: any, index: number) =>
new Area({
data: lineData,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
xScale: this.xScale,
yScale: this.yScale,
color: this.colors[index],
defined: this.defined
})
// if area is boolean and truthy, generate areas for each line
if (typeof area === 'boolean' && area) {
areas = this.data.map(areaGenerator)
// if area is array, only show areas for the truthy lines
} else if (Array.isArray(area)) {
areas = this.data.filter((lineData, index) => area[index]).map(areaGenerator)
}
// mount areas
areas.forEach((area) => area.mountTo(this.container))
}
/**
* Mount the confidence band specified by two accessors.
*
* @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).
* @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).
*/
mountConfidenceBand([lowerAccessor, upperAccessor]: ConfidenceBand): void {
// abort if container is not set
if (!this.container) {
console.error('error: container not defined yet')
return
}
const confidenceBandGenerator = new Area({
data: this.data[0], // confidence band only makes sense for one line
xAccessor: this.xAccessor,
y0Accessor: makeAccessorFunction(lowerAccessor),
yAccessor: makeAccessorFunction(upperAccessor),
xScale: this.xScale,
yScale: this.yScale,
color: '#aaa'
})
confidenceBandGenerator.mountTo(this.container)
}
/**
* Mount markers, if any.
*/
mountMarkers(): void {
// abort if content is not set yet
if (!this.content) {
console.error('error: content container not set yet')
return
}
const markerContainer = this.content.append('g').attr('transform', `translate(${this.left},${this.top})`)
this.markers.forEach((marker) => {
const x = this.xScale.scaleObject(this.xAccessor(marker))
markerContainer
.append('line')
.classed('line-marker', true)
.attr('x1', x!)
.attr('x2', x!)
.attr('y1', this.yScale.range[0] + this.buffer)
.attr('y2', this.yScale.range[1] + this.buffer)
markerContainer.append('text').classed('text-marker', true).attr('x', x!).attr('y', 8).text(marker.label)
})
}
mountBaselines(): void {
// abort if content is not set yet
if (!this.content) {
console.error('error: content container not set yet')
return
}
const baselineContainer = this.content.append('g').attr('transform', `translate(${this.left},${this.top})`)
this.baselines.forEach((baseline) => {
const y = this.yScale.scaleObject(this.yAccessor(baseline))
baselineContainer
.append('line')
.classed('line-baseline', true)
.attr('x1', this.xScale.range[0] + this.buffer)
.attr('x2', this.xScale.range[1] + this.buffer)
.attr('y1', y!)
.attr('y2', y!)
baselineContainer
.append('text')
.classed('text-baseline', true)
.attr('x', this.xScale.range[1] + this.buffer)
.attr('y', y! - 2)
.text(baseline.label)
})
}
/**
* Handle incoming points from the delaunay move handler.
*
* @returns handler function.
*/
onPointHandler(): InteractionFunction {
return (points) => {
// pre-hide all points
this.delaunayPoints.forEach((dp) => dp.dismount())
points.forEach((point) => {
const index = point.arrayIndex || 0
// set hover point
this.delaunayPoints[index].update({
data: point,
color: this.colors[index]
})
this.delaunayPoints[index].mountTo(this.container)
})
// set tooltip if necessary
if (!this.tooltip) return
this.tooltip.update({ data: points })
}
}
/**
* Handles leaving the delaunay area.
*
* @returns handler function.
*/
onLeaveHandler(): EmptyInteractionFunction {
return () => {
this.delaunayPoints.forEach((dp) => dp.dismount())
if (this.tooltip) this.tooltip.hide()
}
}
/**
* Mount a new delaunay triangulation instance.
*
* @param customParameters custom parameters for {@link Delaunay}.
*/
mountDelaunay(customParameters: Partial): void {
// abort if container is not set yet
if (!this.container) {
console.error('error: container not set yet')
return
}
this.delaunay = new Delaunay({
points: this.data,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
xScale: this.xScale,
yScale: this.yScale,
onPoint: this.onPointHandler(),
onLeave: this.onLeaveHandler(),
defined: this.defined,
...customParameters
})
this.delaunay.mountTo(this.container)
}
computeYAxisType(): void {
// abort if no y axis is used
if (!this.yAxis) {
console.error('error: no y axis set')
return
}
const flatData = this.data.flat()
const yValue = this.yAccessor(flatData[0])
if (yValue instanceof Date) {
this.yAxis.tickFormat = constants.axisFormat.date
} else if (Number(yValue) === yValue) {
this.yAxis.tickFormat = constants.axisFormat.number
}
}
}
================================================
FILE: lib/src/charts/scatter.ts
================================================
import Delaunay from '../components/delaunay'
import Rug, { RugOrientation } from '../components/rug'
import { makeAccessorFunction } from '../misc/utility'
import { AccessorFunction, LegendSymbol, InteractionFunction, EmptyInteractionFunction } from '../misc/typings'
import Point from '../components/point'
import { TooltipSymbol } from '../components/tooltip'
import AbstractChart, { IAbstractChart } from './abstractChart'
interface IScatterChart extends IAbstractChart {
/** 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) */
sizeAccessor?: string | AccessorFunction
/** whether or not to generate a rug for the x axis */
xRug?: boolean
/** whether or not to generate a rug for the x axis */
yRug?: boolean
}
interface ActivePoint {
i: number
j: number
}
export default class ScatterChart extends AbstractChart {
points?: Array
delaunay?: Delaunay
delaunayPoint?: Point
sizeAccessor: AccessorFunction
showXRug: boolean
xRug?: Rug
showYRug: boolean
yRug?: Rug
_activePoint: ActivePoint = { i: -1, j: -1 }
constructor({ sizeAccessor, xRug, yRug, ...args }: IScatterChart) {
super(args)
this.showXRug = xRug ?? false
this.showYRug = yRug ?? false
this.sizeAccessor = sizeAccessor ? makeAccessorFunction(sizeAccessor) : () => 3
this.redraw()
}
redraw(): void {
// set up rugs if necessary
this.mountRugs()
// set tooltip type
if (this.tooltip) {
this.tooltip.update({ legendObject: TooltipSymbol.CIRCLE })
this.tooltip.hide()
}
// set up points
this.mountPoints()
// generate delaunator
this.mountDelaunay()
// mount legend if any
this.mountLegend(LegendSymbol.CIRCLE)
// mount brush if necessary
this.mountBrush(this.brush)
}
/**
* Mount new rugs.
*/
mountRugs(): void {
// if content is not set yet, abort
if (!this.content) {
console.error('error: content not set yet')
return
}
if (this.showXRug) {
this.xRug = new Rug({
accessor: this.xAccessor,
scale: this.xScale,
colors: this.colors,
data: this.data,
left: this.plotLeft,
top: this.innerHeight + this.plotTop + this.buffer,
orientation: RugOrientation.HORIZONTAL // TODO how to pass tickLength etc?
})
this.xRug.mountTo(this.content)
}
if (this.showYRug) {
this.yRug = new Rug({
accessor: this.yAccessor,
scale: this.yScale,
colors: this.colors,
data: this.data,
left: this.left,
top: this.plotTop,
orientation: RugOrientation.VERTICAL
})
this.yRug.mountTo(this.content)
}
}
/**
* Mount scatter points.
*/
mountPoints(): void {
// if container is not set yet, abort
if (!this.container) {
console.error('error: container not set yet')
return
}
this.points = this.data.map((pointSet, i) =>
pointSet.map((data: any) => {
const point = this.generatePoint({
data,
color: this.colors[i],
radius: this.sizeAccessor(data) as number,
fillOpacity: 0.3,
strokeWidth: 1
})
point.mountTo(this.container!)
return point
})
)
}
/**
* Handle incoming points from the delaunay triangulation.
*
* @returns handler function
*/
onPointHandler(): InteractionFunction {
return ([point]) => {
this.activePoint = { i: point.arrayIndex ?? 0, j: point.index }
// set tooltip if necessary
if (!this.tooltip) return
this.tooltip.update({ data: [point] })
}
}
/**
* Handle leaving the delaunay area.
*
* @returns handler function
*/
onLeaveHandler(): EmptyInteractionFunction {
return () => {
this.activePoint = { i: -1, j: -1 }
if (this.tooltip) this.tooltip.hide()
}
}
/**
* Mount new delaunay triangulation instance.
*/
mountDelaunay(): void {
// if container is not set yet, abort
if (!this.container) {
console.error('error: container not set yet')
return
}
this.delaunayPoint = this.generatePoint({ radius: 3 })
this.delaunay = new Delaunay({
points: this.data,
xAccessor: this.xAccessor,
yAccessor: this.yAccessor,
xScale: this.xScale,
yScale: this.yScale,
nested: true,
onPoint: this.onPointHandler(),
onLeave: this.onLeaveHandler()
})
this.delaunay.mountTo(this.container)
}
get activePoint() {
return this._activePoint
}
set activePoint({ i, j }: ActivePoint) {
// abort if points are not set yet
if (!this.points) {
console.error('error: cannot set point, as points are not set')
return
}
// if a point was previously set, de-set it
if (this._activePoint.i !== -1 && this._activePoint.j !== -1) {
this.points[this._activePoint.i][this._activePoint.j].update({
fillOpacity: 0.3
})
}
// set state
this._activePoint = { i, j }
// set point to active
if (i !== -1 && j !== -1) this.points[i][j].update({ fillOpacity: 1 })
}
}
================================================
FILE: lib/src/components/abstractShape.ts
================================================
import { SvgD3Selection } from '../misc/typings'
import Scale from './scale'
export interface IAbstractShape {
/** datapoint used to generate shape */
data?: any
/** scale used to compute x values */
xScale: Scale
/** scale used to compute y values */
yScale: Scale
/** color used for fill and strokes */
color?: string
/** opacity of the shape fill */
fillOpacity?: number
/** width of the stroke around the shape */
strokeWidth?: number
}
export default abstract class AbstractShape {
data: any
shapeObject: any
xScale: Scale
yScale: Scale
color: string
fillOpacity = 1
strokeWidth = 0
constructor({ data, xScale, yScale, color, fillOpacity, strokeWidth }: IAbstractShape) {
this.data = data
this.xScale = xScale
this.yScale = yScale
this.color = color ?? 'black'
this.fillOpacity = fillOpacity ?? this.fillOpacity
this.strokeWidth = strokeWidth ?? this.strokeWidth
}
/**
* Render the shape and mount it to the given node.
* Implemented by classes extending AbstractShape.
*
* @param svg D3 node to mount the shape to
*/
abstract mountTo(svg: SvgD3Selection): void
/**
* Hide the shape by setting the opacity to 0. This doesn't remove the shape.
*/
hide(): void {
if (this.shapeObject) this.shapeObject.attr('opacity', 0)
}
/**
* Update the given parameters of the object.
* Implemented by classes extending AbstractShape.
*
* @param args parameters to be updated
*/
abstract update(args: any): void
/**
* Update generic properties of the shape.
* This method can be used in the implementations of {@link AbstractShape#update}.
*
* @param color new color of the shape.
* @param fillOpacity new fill opacity of the shape.
* @param strokeWidth new stroke width of the shape.
*/
updateGeneric({
color,
fillOpacity,
strokeWidth
}: Pick): void {
if (color) this.updateColor(color)
if (fillOpacity) this.updateOpacity(fillOpacity)
if (strokeWidth) this.updateStroke(strokeWidth)
}
/**
* Update the color of the shape.
*
* @param color new color of the shape.
*/
updateColor(color: string): void {
this.color = color
this.updateProp('fill', color)
}
/**
* Update the fill opacity of the shape.
*
* @param fillOpacity new fill opacity of the shape.
*/
updateOpacity(fillOpacity: number): void {
this.fillOpacity = fillOpacity
this.updateProp('fill-opacity', fillOpacity)
}
/**
* Update the stroke width of the shape.
*
* @param strokeWidth new stroke width of the shape.
*/
updateStroke(strokeWidth: number): void {
this.strokeWidth = strokeWidth
this.updateProp('stroke-width', strokeWidth)
}
/**
* Update an attribute of the raw shape node.
*
* @param name attribute name
* @param value new value
*/
updateProp(name: string, value: number | string): void {
if (this.shapeObject) this.shapeObject.attr(name, value)
}
/**
* Remove the shape.
*/
dismount(): void {
if (this.shapeObject) this.shapeObject.remove()
}
}
================================================
FILE: lib/src/components/area.ts
================================================
import { area, curveCatmullRom, CurveFactory } from 'd3'
import { AccessorFunction, DefinedFunction, SvgD3Selection } from '../misc/typings'
import Scale from './scale'
interface IArea {
/** data for which the area should be created */
data: Array
/** x accessor function */
xAccessor: AccessorFunction
/** y accessor function */
yAccessor: AccessorFunction
/** y base accessor function (defaults to 0) */
y0Accessor?: AccessorFunction
/** alternative to yAccessor */
y1Accessor?: AccessorFunction
/** scale used to scale elements in x direction */
xScale: Scale
/** scale used to scale elements in y direction */
yScale: Scale
/** curving function. See {@link https://github.com/d3/d3-shape#curves} for available curves in d3 */
curve?: CurveFactory
/** color of the area */
color?: string
/** specifies whether or not to show a given datapoint */
defined?: DefinedFunction
}
export default class Area {
data: Array
areaObject?: any
index = 0
color = 'none'
constructor({ data, xAccessor, yAccessor, y0Accessor, y1Accessor, xScale, yScale, curve, color, defined }: IArea) {
this.data = data
this.color = color ?? this.color
const y0 = y0Accessor ?? ((d) => 0)
const y1 = y1Accessor ?? yAccessor
// set up line object
this.areaObject = area()
.defined((d) => {
if (y0(d) === null || y1(d) === null) return false
return !defined ? true : defined(d)
})
.x((d) => xScale.scaleObject(xAccessor(d)))
.y1((d) => yScale.scaleObject(y1(d)))
.y0((d) => yScale.scaleObject(y0(d)))
.curve(curve ?? curveCatmullRom)
}
/**
* Mount the area to a given d3 node.
*
* @param svg d3 node to mount the area to.
*/
mountTo(svg: SvgD3Selection): void {
svg.append('path').classed('mg-area', true).attr('fill', this.color).datum(this.data).attr('d', this.areaObject)
}
}
================================================
FILE: lib/src/components/axis.ts
================================================
import { axisTop, axisLeft, axisRight, axisBottom, format, timeFormat } from 'd3'
import constants from '../misc/constants'
import { GD3Selection, LineD3Selection, TextD3Selection, TextFunction } from '../misc/typings'
import Scale from './scale'
const DEFAULT_VERTICAL_OFFSET = 35
const DEFAULT_HORIZONTAL_OFFSET = 45
type NumberFormatFunction = (x: number) => string
type DateFormatFunction = (x: Date) => string
type FormatFunction = NumberFormatFunction | DateFormatFunction
export enum AxisOrientation {
TOP = 'top',
BOTTOM = 'bottom',
RIGHT = 'right',
LEFT = 'left'
}
enum AxisFormat {
DATE = 'date',
NUMBER = 'number',
PERCENTAGE = 'percentage'
}
export interface IAxis {
/** scale of the axis */
scale: Scale
/** buffer used by the chart, necessary to compute margins */
buffer: number
/** whether or not to show the axis */
show?: boolean
/** orientation of the axis */
orientation?: AxisOrientation
/** optional label to place beside the axis */
label?: string
/** offset between label and axis */
labelOffset?: number
/** translation from the top of the chart's box to render the axis */
top?: number
/** translation from the left of the chart's to render the axis */
left?: number
/** 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 */
tickFormat?: TextFunction | AxisFormat | string
/** number of ticks to render, defaults to 3 for vertical and 6 for horizontal axes */
tickCount?: number
/** whether or not to render a compact version of the axis (clamps the main axis line at the outermost ticks) */
compact?: boolean
/** prefix for tick labels */
prefix?: string
/** suffix for tick labels */
suffix?: string
/** overwrite d3's default tick lengths */
tickLength?: number
/** draw extended ticks into the graph (used to make a grid) */
extendedTicks?: boolean
/** if extended ticks are used, this parameter specifies the inner length of ticks */
height?: number
}
export default class Axis {
label = ''
labelOffset = 0
top = 0
left = 0
scale: Scale
orientation = AxisOrientation.BOTTOM
axisObject: any
compact = false
extendedTicks = false
buffer = 0
height = 0
prefix = ''
suffix = ''
constructor({
orientation,
label,
labelOffset,
top,
left,
height,
scale,
tickFormat,
tickCount,
compact,
buffer,
prefix,
suffix,
tickLength,
extendedTicks
}: IAxis) {
this.scale = scale
this.label = label ?? this.label
this.buffer = buffer ?? this.buffer
this.top = top ?? this.top
this.left = left ?? this.left
this.height = height ?? this.height
this.orientation = orientation ?? this.orientation
this.compact = compact ?? this.compact
this.prefix = prefix ?? this.prefix
this.suffix = suffix ?? this.suffix
if (typeof tickLength !== 'undefined') this.tickLength = tickLength
this.extendedTicks = extendedTicks ?? this.extendedTicks
this.setLabelOffset(labelOffset)
this.setupAxisObject()
// set or compute tickFormat
if (tickFormat) this.tickFormat = tickFormat
this.tickCount = tickCount ?? (this.isVertical ? 3 : 6)
}
/**
* Set the label offset.
*
* @param labelOffset offset of the label.
*/
setLabelOffset(labelOffset?: number): void {
this.labelOffset =
typeof labelOffset !== 'undefined'
? labelOffset
: this.isVertical
? DEFAULT_HORIZONTAL_OFFSET
: DEFAULT_VERTICAL_OFFSET
}
/**
* Set up the main axis object.
*/
setupAxisObject(): void {
switch (this.orientation) {
case constants.axisOrientation.top:
this.axisObject = axisTop(this.scale.scaleObject)
break
case constants.axisOrientation.left:
this.axisObject = axisLeft(this.scale.scaleObject)
break
case constants.axisOrientation.right:
this.axisObject = axisRight(this.scale.scaleObject)
break
default:
this.axisObject = axisBottom(this.scale.scaleObject)
break
}
}
/**
* Get the domain object call function.
* @returns that mounts the domain when called.
*/
domainObject() {
return (g: GD3Selection): LineD3Selection =>
g
.append('line')
.classed('domain', true)
.attr('x1', this.isVertical ? 0.5 : this.compact ? this.buffer : 0)
.attr('x2', this.isVertical ? 0.5 : this.compact ? this.scale.range[1] : this.scale.range[1] + 2 * this.buffer)
.attr('y1', this.isVertical ? (this.compact ? this.top + 0.5 : 0.5) : 0)
.attr(
'y2',
this.isVertical ? (this.compact ? this.scale.range[0] + 0.5 : this.scale.range[0] + 2 * this.buffer + 0.5) : 0
)
}
/**
* Get the label object call function.
* @returns {Function} that mounts the label when called.
*/
labelObject(): (node: GD3Selection) => TextD3Selection {
const value = Math.abs(this.scale.range[0] - this.scale.range[1]) / 2
const xValue = this.isVertical ? -this.labelOffset : value
const yValue = this.isVertical ? value : this.labelOffset
return (g) =>
g
.append('text')
.attr('x', xValue)
.attr('y', yValue)
.attr('text-anchor', 'middle')
.classed('label', true)
.attr('transform', this.isVertical ? `rotate(${-90} ${xValue},${yValue})` : '')
.text(this.label)
}
get isVertical(): boolean {
return [constants.axisOrientation.left, constants.axisOrientation.right].includes(this.orientation)
}
get innerLeft(): number {
return this.isVertical ? 0 : this.buffer
}
get innerTop(): number {
return this.isVertical ? this.buffer : 0
}
get tickAttribute(): string {
return this.isVertical ? 'x1' : 'y1'
}
get extendedTickLength(): number {
const factor = this.isVertical ? 1 : -1
return factor * (this.height + 2 * this.buffer)
}
/**
* Mount the axis to the given d3 node.
* @param svg d3 node.
*/
mountTo(svg: GD3Selection): void {
// set up axis container
const axisContainer = svg
.append('g')
.attr('transform', `translate(${this.left},${this.top})`)
.classed('mg-axis', true)
// if no extended ticks are used, draw the domain line
if (!this.extendedTicks) axisContainer.call(this.domainObject())
// mount axis but remove default-generated domain
axisContainer
.append('g')
.attr('transform', `translate(${this.innerLeft},${this.innerTop})`)
.call(this.axisObject)
.call((g) => g.select('.domain').remove())
// if necessary, make ticks longer
if (this.extendedTicks) {
axisContainer.call((g) =>
g.selectAll('.tick line').attr(this.tickAttribute, this.extendedTickLength).attr('opacity', 0.3)
)
}
// if necessary, add label
if (this.label !== '') axisContainer.call(this.labelObject())
}
/**
* Compute the time formatting function based on the time domain.
* @returns d3 function for formatting time.
*/
diffToTimeFormat(): FormatFunction {
const diff = Math.abs(this.scale.domain[1] - this.scale.domain[0]) / 1000
const millisecondDiff = diff < 1
const secondDiff = diff < 60
const dayDiff = diff / (60 * 60) < 24
const fourDaysDiff = diff / (60 * 60) < 24 * 4
const manyDaysDiff = diff / (60 * 60 * 24) < 60
const manyMonthsDiff = diff / (60 * 60 * 24) < 365
return millisecondDiff
? timeFormat('%M:%S.%L')
: secondDiff
? timeFormat('%M:%S')
: dayDiff
? timeFormat('%H:%M')
: fourDaysDiff || manyDaysDiff || manyMonthsDiff
? timeFormat('%b %d')
: timeFormat('%Y')
}
/**
* Get the d3 number formatting function for an abstract number type.
*
* @param formatType abstract format to be converted (number, date, percentage)
* @returns d3 formatting function for the given abstract number type.
*/
stringToFormat(formatType: AxisFormat | string): FormatFunction {
switch (formatType) {
case constants.axisFormat.number:
return this.isVertical ? format('~s') : format('')
case constants.axisFormat.date:
return this.diffToTimeFormat()
case constants.axisFormat.percentage:
return format('.0%')
default:
return format(formatType)
}
}
get tickFormat() {
return this.axisObject.tickFormat()
}
set tickFormat(tickFormat: FormatFunction | string) {
// if tickFormat is a function, apply it directly
const formatFunction = typeof tickFormat === 'function' ? tickFormat : this.stringToFormat(tickFormat)
this.axisObject.tickFormat((d: any) => `${this.prefix}${formatFunction(d)}${this.suffix}`)
}
get tickCount() {
return this.axisObject.ticks()
}
set tickCount(tickCount: number) {
this.axisObject.ticks(tickCount)
}
get tickLength() {
return this.axisObject.tickSize()
}
set tickLength(length: number) {
this.axisObject.tickSize(length)
}
}
================================================
FILE: lib/src/components/delaunay.ts
================================================
import { Delaunay as DelaunayObject, pointer } from 'd3'
import {
AccessorFunction,
InteractionFunction,
EmptyInteractionFunction,
DefinedFunction,
GenericD3Selection
} from '../misc/typings'
import Scale from './scale'
export interface IDelaunay {
/** raw data basis for delaunay computations, can be nested */
points: Array
/** function to access the x value for a given data point */
xAccessor: AccessorFunction
/** function to access the y value for a given data point */
yAccessor: AccessorFunction
/** scale used to scale elements in x direction */
xScale: Scale
/** scale used to scale elements in y direction */
yScale: Scale
/** function called with the array of nearest points on mouse movement -- if aggregate is false, the array will contain at most one element */
onPoint?: InteractionFunction
/** function called when the delaunay area is left */
onLeave?: EmptyInteractionFunction
/** 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 */
onClick?: InteractionFunction
/** whether or not the points array contains sub-arrays */
nested?: boolean
/** if multiple points have the same x value and should be shown together, aggregate can be set to true */
aggregate?: boolean
/** optional function specifying whether or not to show a given datapoint */
defined?: DefinedFunction
}
export default class Delaunay {
points?: Array
aggregatedPoints: any
delaunay: any
xScale: Scale
yScale: Scale
xAccessor: AccessorFunction
yAccessor: AccessorFunction
aggregate = false
onPoint: InteractionFunction
onClick?: InteractionFunction
onLeave: EmptyInteractionFunction
constructor({
points,
xAccessor,
yAccessor,
xScale,
yScale,
onPoint,
onLeave,
onClick,
nested,
aggregate,
defined
}: IDelaunay) {
this.xAccessor = xAccessor
this.yAccessor = yAccessor
this.xScale = xScale
this.yScale = yScale
this.onPoint = onPoint ?? (() => null)
this.onLeave = onLeave ?? (() => null)
this.onClick = onClick ?? this.onClick
this.aggregate = aggregate ?? this.aggregate
// normalize passed points
const isNested = nested ?? (Array.isArray(points[0]) && points.length > 1)
this.normalizePoints({ points, nested: isNested, aggregate, defined })
// set up delaunay
this.mountDelaunay(isNested, this.aggregate)
}
/**
* Create a new delaunay triangulation.
*
* @param isNested whether or not the data is nested
* @param aggregate whether or not to aggregate points based on their x value
*/
mountDelaunay(isNested: boolean, aggregate: boolean): void {
// if points are not set yet, stop
if (!this.points) {
console.error('error: points not defined')
return
}
this.delaunay = DelaunayObject.from(
this.points.map((point) => [
this.xAccessor(point) as number,
(isNested && !aggregate ? this.yAccessor(point) : 0) as number
])
)
}
/**
* Normalize the passed data points.
*
* @param {Object} args argument object
* @param {Array} args.points raw data array
* @param {Boolean} args.isNested whether or not the points are nested
* @param {Boolean} args.aggregate whether or not to aggregate points based on their x value
* @param {Function} [args.defined] optional function specifying whether or not to show a given datapoint.
*/
normalizePoints({
points,
nested,
aggregate,
defined
}: Pick): void {
this.points = nested
? points
.map((pointArray, arrayIndex) =>
pointArray
.filter((p: any) => !defined || defined(p))
.map((point: any, index: number) => ({
...point,
index,
arrayIndex
}))
)
.flat(Infinity)
: points
.flat(Infinity)
.filter((p) => !defined || defined(p))
.map((p, index) => ({ ...p, index }))
// if points should be aggregated, hash-map them based on their x accessor value
if (!aggregate) return
this.aggregatedPoints = this.points.reduce((acc, val) => {
const key = JSON.stringify(this.xAccessor(val))
if (!acc.has(key)) {
acc.set(key, [val])
} else {
acc.set(key, [val, ...acc.get(key)])
}
return acc
}, new Map())
}
/**
* Handle raw mouse movement inside the delaunay rect.
* Finds the nearest data point(s) and calls onPoint.
*
* @param rawX raw x coordinate of the cursor.
* @param rawY raw y coordinate of the cursor.
*/
gotPoint(rawX: number, rawY: number): void {
// if points are empty, return
if (!this.points) {
console.error('error: points are empty')
return
}
const x = this.xScale.scaleObject.invert(rawX)
const y = this.yScale.scaleObject.invert(rawY)
// find nearest point
const index = this.delaunay.find(x, y)
// if points should be aggregated, get all points with the same x value
if (this.aggregate) {
this.onPoint(this.aggregatedPoints.get(JSON.stringify(this.xAccessor(this.points[index]))))
} else {
this.onPoint([this.points[index]])
}
}
/**
* Handle raw mouse clicks inside the delaunay rect.
* Finds the nearest data point(s) and calls onClick.
*
* @param rawX raw x coordinate of the cursor.
* @param rawY raw y coordinate of the cursor.
*/
clickedPoint(rawX: number, rawY: number): void {
// if points empty, abort
if (!this.points) {
console.error('error: points empty')
return
}
const x = this.xScale.scaleObject.invert(rawX)
const y = this.yScale.scaleObject.invert(rawY)
// find nearest point
const index = this.delaunay.find(x, y)
if (this.onClick) this.onClick({ ...this.points[index], index })
}
/**
* Mount the delaunator to a given d3 node.
*
* @param svg d3 selection to mount the delaunay elements to.
*/
mountTo(svg: GenericD3Selection): void {
svg.on('mousemove', (event) => {
const coords = pointer(event)
this.gotPoint(coords[0], coords[1])
})
svg.on('mouseleave', () => {
this.onLeave()
})
svg.on('click', (event) => {
const coords = pointer(event)
this.clickedPoint(coords[0], coords[1])
})
}
}
================================================
FILE: lib/src/components/legend.ts
================================================
import { select } from 'd3'
import constants from '../misc/constants'
import { LegendSymbol } from '../misc/typings'
interface ILegend {
/** array of descriptive legend strings */
legend: Array
/** colors used for the legend -- will be darkened for better visibility */
colorScheme: Array
/** symbol used in the legend */
symbolType: LegendSymbol
}
export default class Legend {
legend: Array
colorScheme: Array
symbolType: LegendSymbol
constructor({ legend, colorScheme, symbolType }: ILegend) {
this.legend = legend
this.colorScheme = colorScheme
this.symbolType = symbolType
}
/**
* Darken a given color by a given amount.
*
* @see https://css-tricks.com/snippets/javascript/lighten-darken-color/
* @param color hex color specifier
* @param amount how much to darken the color
* @returns darkened color in hex representation.
*/
darkenColor(color: string, amount: number): string {
// remove hash
color = color.slice(1)
const num = parseInt(color, 16)
const r = this.clamp((num >> 16) + amount)
const b = this.clamp(((num >> 8) & 0x00ff) + amount)
const g = this.clamp((num & 0x0000ff) + amount)
return '#' + (g | (b << 8) | (r << 16)).toString(16)
}
/**
* Clamp a number between 0 and 255.
*
* @param number number to be clamped.
* @returns clamped number.
*/
clamp(number: number): number {
return number > 255 ? 255 : number < 0 ? 0 : number
}
/**
* Mount the legend to the given node.
*
* @param node d3 specifier or d3 node to mount the legend to.
*/
mountTo(node: any) {
const symbol = constants.symbol[this.symbolType]
// create d3 selection if necessary
const target = typeof node === 'string' ? select(node).append('div') : node.append('div')
target.classed('mg-legend', true)
this.legend.forEach((item, index) => {
target
.append('span')
.classed('text-legend', true)
.style('color', this.darkenColor(this.colorScheme[index], -10))
.text(`${symbol} ${item}`)
})
}
}
================================================
FILE: lib/src/components/line.ts
================================================
import { line, curveCatmullRom, CurveFactory } from 'd3'
import { AccessorFunction, SvgD3Selection } from '../misc/typings'
import Scale from './scale'
interface ILine {
/** array of datapoints used to create the line */
data: Array
/** function to access the x value for a given datapoint */
xAccessor: AccessorFunction
/** function to access the y value for a given datapoint */
yAccessor: AccessorFunction
/** scale used to compute x values */
xScale: Scale
/** scale used to compute y values */
yScale: Scale
/** curving function used to draw the line. See {@link https://github.com/d3/d3-shape#curves} for curves available in d3 */
curve?: CurveFactory
/** color of the line */
color?: string
/** function specifying whether or not to show a given datapoint */
defined?: (datapoint: any) => boolean
}
export default class Line {
lineObject?: any
data: Array
color: string
constructor({ data, xAccessor, yAccessor, xScale, yScale, curve, color, defined }: ILine) {
// cry if no data was passed
if (!data) throw new Error('line needs data')
this.data = data
this.color = color ?? 'black'
// set up line object
this.lineObject = line()
.defined((d) => {
if (yAccessor(d) === null) return false
if (typeof defined === 'undefined') return true
return defined(d)
})
.x((d) => xScale.scaleObject(xAccessor(d)))
.y((d) => yScale.scaleObject(yAccessor(d)))
.curve(curve ?? curveCatmullRom)
}
/**
* Mount the line to the given d3 node.
*
* @param svg d3 node to mount the line to.
*/
mountTo(svg: SvgD3Selection): void {
svg.append('path').classed('mg-line', true).attr('stroke', this.color).datum(this.data).attr('d', this.lineObject)
}
}
================================================
FILE: lib/src/components/point.ts
================================================
import { AccessorFunction, SvgD3Selection } from '../misc/typings'
import AbstractShape, { IAbstractShape } from './abstractShape'
export interface IPoint extends IAbstractShape {
/** function to access the x value of the point */
xAccessor: AccessorFunction
/** function to access the y value of the point */
yAccessor: AccessorFunction
/** radius of the point */
radius?: number
}
export default class Point extends AbstractShape {
xAccessor: AccessorFunction
yAccessor: AccessorFunction
radius = 1
constructor({ xAccessor, yAccessor, radius, ...args }: IPoint) {
super(args)
this.xAccessor = xAccessor
this.yAccessor = yAccessor
this.radius = radius ?? this.radius
}
get cx(): number {
return this.xScale.scaleObject(this.xAccessor(this.data))
}
get cy(): number {
return this.yScale.scaleObject(this.yAccessor(this.data))
}
/**
* Mount the point to the given node.
*
* @param svg d3 node to mount the point to.
*/
mountTo(svg: SvgD3Selection): void {
this.shapeObject = svg
.append('circle')
.attr('cx', this.cx)
.attr('pointer-events', 'none')
.attr('cy', this.cy)
.attr('r', this.radius)
.attr('fill', this.color)
.attr('stroke', this.color)
.attr('fill-opacity', this.fillOpacity)
.attr('stroke-width', this.strokeWidth)
}
/**
* Update the point.
*
* @param data point object
*/
update({ data, ...args }: IAbstractShape): void {
this.updateGeneric(args)
if (data) {
this.data = data
if (!this.shapeObject) return
this.shapeObject.attr('cx', this.cx).attr('cy', this.cy).attr('opacity', 1)
}
}
}
================================================
FILE: lib/src/components/rect.ts
================================================
import { AccessorFunction, GenericD3Selection } from '../misc/typings'
import AbstractShape, { IAbstractShape } from './abstractShape'
interface IRect extends IAbstractShape {
/** function to access the x value of the rectangle */
xAccessor: AccessorFunction
/** function to access the y value of the rectangle */
yAccessor: AccessorFunction
/** function to access the width of the rectangle */
widthAccessor: AccessorFunction
/** function to access the height of the rectangle */
heightAccessor: AccessorFunction
}
export default class Rect extends AbstractShape {
xAccessor: AccessorFunction
yAccessor: AccessorFunction
widthAccessor: AccessorFunction
heightAccessor: AccessorFunction
constructor({ xAccessor, yAccessor, widthAccessor, heightAccessor, ...args }: IRect) {
super(args)
this.xAccessor = xAccessor
this.yAccessor = yAccessor
this.widthAccessor = widthAccessor
this.heightAccessor = heightAccessor
}
get x(): number {
return this.xScale.scaleObject(this.xAccessor(this.data))
}
get y(): number {
return this.yScale.scaleObject(this.yAccessor(this.data))
}
get width(): number {
return Math.max(0, Math.abs(this.widthAccessor(this.data)))
}
get height(): number {
return Math.max(0, this.yScale.scaleObject(this.heightAccessor(this.data)))
}
/**
* Mount the rectangle to the given node.
*
* @param svg d3 node to mount the rectangle to.
*/
mountTo(svg: GenericD3Selection): void {
this.shapeObject = svg
.append('rect')
.attr('x', this.x)
.attr('y', this.y)
.attr('width', this.width)
.attr('height', this.height)
.attr('pointer-events', 'none')
.attr('fill', this.color)
.attr('stroke', this.color)
.attr('fill-opacity', this.fillOpacity)
.attr('stroke-width', this.strokeWidth)
}
/**
* Update the rectangle.
*
* @param data updated data object.
*/
update({ data, ...args }: Partial): void {
this.updateGeneric(args)
if (data) {
this.data = data
if (!this.shapeObject) return
this.shapeObject
.attr('x', this.x)
.attr('y', this.y)
.attr('width', this.width)
.attr('height', this.height)
.attr('opacity', 1)
}
}
}
================================================
FILE: lib/src/components/rug.ts
================================================
import constants from '../misc/constants'
import { AccessorFunction, GenericD3Selection } from '../misc/typings'
import Scale from './scale'
export enum RugOrientation {
HORIZONTAL = 'horizontal',
VERTICAL = 'vertical'
}
interface IRug {
/** accessor used to get the rug value for a given datapoint */
accessor: AccessorFunction
/** scale function of the rug */
scale: Scale
/** data to be rugged */
data: Array
/** length of the rug's ticks */
tickLength?: number
/** color scheme of the rug ticks */
colors?: Array
/** orientation of the rug */
orientation?: RugOrientation
/** left margin of the rug */
left?: number
/** top margin of the rug */
top?: number
}
export default class Rug {
accessor: AccessorFunction
scale: Scale
rugObject: any
data: Array
left = 0
top = 0
tickLength = 5
colors = constants.defaultColors
orientation = RugOrientation.HORIZONTAL
constructor({ accessor, scale, data, tickLength, colors, orientation, left, top }: IRug) {
this.accessor = accessor
this.scale = scale
this.data = data
this.tickLength = tickLength ?? this.tickLength
this.colors = colors ?? this.colors
this.orientation = orientation ?? this.orientation
this.left = left ?? this.left
this.top = top ?? this.top
}
get isVertical(): boolean {
return this.orientation === constants.orientation.vertical
}
/**
* Mount the rug to the given node.
*
* @param svg d3 node to mount the rug to.
*/
mountTo(svg: GenericD3Selection): void {
// add container
const top = this.isVertical ? this.top : this.top - this.tickLength
const container = svg.append('g').attr('transform', `translate(${this.left},${top})`)
// add lines
this.data.forEach((dataArray, i) =>
dataArray.forEach((datum: any) => {
const value = this.scale.scaleObject(this.accessor(datum))
container
.append('line')
.attr(this.isVertical ? 'y1' : 'x1', value!)
.attr(this.isVertical ? 'y2' : 'x2', value!)
.attr(this.isVertical ? 'x1' : 'y1', 0)
.attr(this.isVertical ? 'x2' : 'y2', this.tickLength)
.attr('stroke', this.colors[i])
})
)
}
}
================================================
FILE: lib/src/components/scale.ts
================================================
import { scaleLinear, ScaleLinear } from 'd3'
import { Domain, Range } from '../misc/typings'
enum ScaleType {
LINEAR = 'linear'
}
type SupportedScale = ScaleLinear
interface IScale {
/** type of scale (currently only linear) */
type?: ScaleType
/** scale range */
range?: Range
/** scale domain */
domain?: Domain
/** overwrites domain lower bound */
minValue?: number
/** overwrites domain upper bound */
maxValue?: number
}
export default class Scale {
type: ScaleType
scaleObject: SupportedScale
minValue?: number
maxValue?: number
constructor({ type, range, domain, minValue, maxValue }: IScale) {
this.type = type ?? ScaleType.LINEAR
this.scaleObject = this.getScaleObject(this.type)
// set optional custom ranges and domains
if (range) this.range = range
if (domain) this.domain = domain
// set optional min and max
this.minValue = minValue
this.maxValue = maxValue
}
/**
* Get the d3 scale object for a given scale type.
*
* @param {String} type scale type
* @returns {Object} d3 scale type
*/
getScaleObject(type: ScaleType): SupportedScale {
switch (type) {
default:
return scaleLinear()
}
}
get range(): Range {
return this.scaleObject.range()
}
set range(range: Range) {
this.scaleObject.range(range)
}
get domain(): Domain {
return this.scaleObject.domain()
}
set domain(domain: Domain) {
// fix custom domain values if necessary
if (typeof this.minValue !== 'undefined') domain[0] = this.minValue
if (typeof this.maxValue !== 'undefined') domain[1] = this.maxValue
this.scaleObject.domain(domain)
}
}
================================================
FILE: lib/src/components/tooltip.ts
================================================
import constants from '../misc/constants'
import { TextFunction, AccessorFunction, GenericD3Selection } from '../misc/typings'
export enum TooltipSymbol {
CIRCLE = 'circle',
LINE = 'line',
SQUARE = 'square'
}
interface ITooltip {
/** symbol to show in the tooltip (defaults to line) */
legendObject?: TooltipSymbol
/** description of the different data arrays shown in the legend */
legend?: Array
/** array of colors for the different data arrays, defaults to schemeCategory10 */
colors?: Array
/** custom text formatting function -- generated from accessors if not defined */
textFunction?: TextFunction
/** entries to show in the tooltip, usually empty when first instantiating */
data?: Array
/** margin to the left of the tooltip */
left?: number
/** margin to the top of the tooltip */
top?: number
/** if no custom text function is specified, specifies how to get the x value from a specific data point */
xAccessor?: AccessorFunction
/** if no custom text function is specified, specifies how to get the y value from a specific data point */
yAccessor?: AccessorFunction
}
export default class Tooltip {
legendObject = TooltipSymbol.LINE
legend: Array
colors = constants.defaultColors
data: Array
left = 0
top = 0
node: any
textFunction = (x: any) => 'bla'
constructor({ legendObject, legend, colors, textFunction, data, left, top, xAccessor, yAccessor }: ITooltip) {
this.legendObject = legendObject ?? this.legendObject
this.legend = legend ?? []
this.colors = colors ?? this.colors
this.setTextFunction(textFunction, xAccessor, yAccessor)
this.data = data ?? []
this.left = left ?? this.left
this.top = top ?? this.top
}
/**
* Sets the text function for the tooltip.
*
* @param textFunction custom text function for the tooltip text. Generated from xAccessor and yAccessor if not
* @param xAccessor if no custom text function is specified, this function specifies how to get the x value from a specific data point.
* @param yAccessor if no custom text function is specified, this function specifies how to get the y value from a specific data point.
*/
setTextFunction(textFunction?: TextFunction, xAccessor?: AccessorFunction, yAccessor?: AccessorFunction): void {
this.textFunction =
textFunction || (xAccessor && yAccessor ? this.baseTextFunction(xAccessor, yAccessor) : this.textFunction)
}
/**
* If no textFunction was specified when creating the tooltip instance, this method generates a text function based on the xAccessor and yAccessor.
*
* @param xAccessor returns the x value of a given data point.
* @param yAccessor returns the y value of a given data point.
* @returns base text function used to render the tooltip for a given datapoint.
*/
baseTextFunction(xAccessor: AccessorFunction, yAccessor: AccessorFunction): TextFunction {
return (point: any) => `${xAccessor(point)}: ${yAccessor(point)}`
}
/**
* Update the tooltip.
*/
update({ data, legendObject, legend }: Pick): void {
this.data = data ?? this.data
this.legendObject = legendObject ?? this.legendObject
this.legend = legend ?? this.legend
this.addText()
}
/**
* Hide the tooltip (without destroying it).
*/
hide(): void {
this.node.attr('opacity', 0)
}
/**
* Mount the tooltip to the given d3 node.
*
* @param svg d3 node to mount the tooltip to.
*/
mountTo(svg: GenericD3Selection): void {
this.node = svg
.append('g')
.style('font-size', '0.7rem')
.attr('transform', `translate(${this.left},${this.top})`)
.attr('opacity', 0)
this.addText()
}
/**
* Adds the text to the tooltip.
* For each datapoint in the data array, one line is added to the tooltip.
*/
addText(): void {
// first, clear existing content
this.node.selectAll('*').remove()
// second, add one line per data entry
this.node.attr('opacity', 1)
this.data.forEach((datum, index) => {
const symbol = constants.symbol[this.legendObject]
const realIndex = datum.arrayIndex ?? index
const color = this.colors[realIndex]
const node = this.node
.append('text')
.attr('text-anchor', 'end')
.attr('y', index * 12)
// category
node.append('tspan').classed('text-category', true).attr('fill', color).text(this.legend[realIndex])
// symbol
node.append('tspan').attr('dx', '6').attr('fill', color).text(symbol)
// text
node
.append('tspan')
.attr('dx', '6')
.text(`${this.textFunction(datum)}`)
})
}
}
================================================
FILE: lib/src/index.ts
================================================
export { default as LineChart } from './charts/line'
export { default as ScatterChart } from './charts/scatter'
export { default as HistogramChart } from './charts/histogram'
================================================
FILE: lib/src/mg.css
================================================
.mg-graph .domain {
stroke: #b3b2b2;
}
.mg-graph .tick line {
stroke: #b3b2b2;
}
.mg-graph .tick text {
font-size: 0.7rem;
fill: black;
opacity: 0.6;
}
.mg-graph .label {
font-size: 0.8rem;
font-weight: 600;
opacity: 0.8;
}
.mg-graph .line-marker {
stroke: #ccc;
stroke-width: 1px;
stroke-dasharray: 2;
}
.mg-graph .line-baseline {
stroke: #ccc;
stroke-width: 1px;
}
.mg-graph .text-marker {
text-anchor: middle;
font-size: 0.7rem;
fill: black;
}
.mg-graph .text-baseline {
text-anchor: end;
font-size: 0.7rem;
fill: black;
}
.mg-graph .text-category {
font-weight: bold;
}
.mg-line {
stroke-width: 1;
fill: none;
}
.mg-area {
fill-opacity: 0.3;
}
.text-legend {
margin-right: 1em;
font-size: 0.7rem;
}
================================================
FILE: lib/src/misc/constants.ts
================================================
const constants = {
chartType: {
line: 'line',
histogram: 'histogram',
bar: 'bar',
point: 'point'
},
axisOrientation: {
top: 'top',
bottom: 'bottom',
left: 'left',
right: 'right'
},
scaleType: {
categorical: 'categorical',
linear: 'linear',
log: 'log'
},
axisFormat: {
date: 'date',
number: 'number',
percentage: 'percentage'
},
legendObject: {
circle: 'circle',
line: 'line',
square: 'square'
},
symbol: {
line: '—',
circle: '•',
square: '■'
},
orientation: {
vertical: 'vertical',
horizontal: 'horizontal'
},
defaultColors: [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf'
]
}
export default constants
================================================
FILE: lib/src/misc/typings.ts
================================================
import { Selection } from 'd3'
export interface AccessorFunction {
(dataObject: X): Y
}
export interface TextFunction {
(dataObject: unknown): string
}
export interface InteractionFunction {
(pointArray: Array): void
}
export interface EmptyInteractionFunction {
(): void
}
export interface DefinedFunction {
(dataObject: unknown): boolean
}
export interface Margin {
left: number
right: number
bottom: number
top: number
}
export interface DomainObject {
x: Domain
y: Domain
}
export enum LegendSymbol {
LINE = 'line',
CIRCLE = 'circle',
SQUARE = 'square'
}
export type BrushType = 'xy' | 'x' | 'y'
export type Domain = number[]
export type Range = number[]
export type GenericD3Selection = Selection
export type SvgD3Selection = Selection
export type GD3Selection = Selection
export type LineD3Selection = Selection
export type TextD3Selection = Selection
================================================
FILE: lib/src/misc/utility.ts
================================================
import { AccessorFunction } from './typings'
/**
* Handle cases where the user specifies an accessor string instead of an accessor function.
*
* @param functionOrString accessor string/function to be made an accessor function
* @returns accessor function
*/
export function makeAccessorFunction(functionOrString: AccessorFunction | string): AccessorFunction {
return typeof functionOrString === 'string' ? (d: any) => d[functionOrString] : functionOrString
}
/**
* Generate a random id.
* Used to create ids for clip paths, which need to be referenced by id.
*
* @returns random id string.
*/
export function randomId(): string {
return Math.random().toString(36).substring(2, 15)
}
================================================
FILE: lib/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2020", "DOM"],
"strict": true,
"sourceMap": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
================================================
FILE: package.json
================================================
{
"private": true,
"workspaces": [
"lib",
"app"
],
"repository": "github:metricsgraphics/metrics-graphics",
"contributors": [
"Ali Almossawi",
"Hamilton Ulmer",
"William Lachance",
"Jens Ochsenmeier"
],
"license": "MPL-2.0",
"bugs": {
"url": "https://github.com/metricsgraphics/metrics-graphics/issues"
},
"homepage": "http://metricsgraphicsjs.org",
"dependencies": {},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.25.0",
"@typescript-eslint/parser": "^5.25.0",
"eslint": "^8.15.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.2.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.5.0",
"prettier": "^2.6.2",
"typescript": "^4.6.4"
}
}