@reside-ic/skadi-chart 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # Skadi Chart
2
+
3
+ This charting library provides a structured and thin wrapper around [d3](https://d3js.org)
4
+ to provide an fully flexible and extensible interface to plot customised graphs that
5
+ out-of-the-box solutions haven't prepared for.
6
+
7
+ There are many examples in [src/demo/App.vue](./src/demo/App.vue) which are used in a Vue context
8
+ however this library will work with TypeScript or JavaScript too. Developer facing docs are in
9
+ [DEV_README](./DEV_README.md).
10
+
11
+ # Installation
12
+
13
+ ```
14
+ npm i @reside-ic/skadi-chart
15
+ ```
16
+
17
+ # Usage
18
+
19
+ ## Example
20
+
21
+ Here is a quick example of using Skadi chart:
22
+
23
+ ```html
24
+ <div id="chart"></div>
25
+ ```
26
+
27
+ ```ts
28
+ import {
29
+ Lines,
30
+ ScatterPoints,
31
+ TooltipHtmlCallback,
32
+ Scales,
33
+ OptionalLayer,
34
+ LayerType,
35
+ LayerArgs
36
+ } from "@reside-ic/skadi-chart";
37
+
38
+ // get element from html document
39
+ const chart = document.getElementById("chart") as HTMLDivElement;
40
+
41
+ // example custom metadata: define a type that you can attach to lines or scatter points
42
+ type Metadata = { type: "line" | "point" }
43
+
44
+ // two straight lines
45
+ const lines: Lines<Metadata> = [
46
+ {
47
+ points: [
48
+ { x: 0, y: 0 },
49
+ { x: 1, y: 1 },
50
+ ],
51
+ style: { color: "black" },
52
+ metadata: { type: "line" }
53
+ },
54
+ {
55
+ points: [
56
+ { x: 0, y: 0 },
57
+ { x: 1, y: 2 },
58
+ ],
59
+ style: { color: "red" },
60
+ metadata: { type: "line" }
61
+ },
62
+ ];
63
+
64
+ // two points
65
+ const points: ScatterPoints<Metadata> = [
66
+ {
67
+ x: 0.5, y: 0.5,
68
+ style: { radius: 2 },
69
+ metadata: { type: "point" }
70
+ },
71
+ {
72
+ x: 0.5, y: 1,
73
+ style: { radius: 1 },
74
+ metadata: { type: "point" }
75
+ },
76
+ ];
77
+
78
+ // custom tooltip with html string
79
+ const tooltipHtmlCallback: TooltipHtmlCallback = ({ x, y, metadata }) => {
80
+ return `<p>Point x=${x}, y=${y} is a ${metadata.type}</p>`
81
+ };
82
+
83
+ // define x axis scale. The chart can compute y by autoscaling based on
84
+ // the data, or you could provide specific values for y here instead.
85
+ const scales: Scales = {
86
+ x: { start: 0, end: 1 }
87
+ };
88
+
89
+ // can extend Skadi chart with whatever functionality you'd like but it
90
+ // must fulfil the OptionalLayer contract
91
+ class CustomLayer extends OptionalLayer {
92
+ type = LayerType.Custom;
93
+ constructor() { super() };
94
+
95
+ // adds a black circle to the svg
96
+ draw(layerArgs: LayerArgs): void {
97
+ const svg = layerArgs.coreLayers[LayerType.Svg];
98
+ const { getHtmlId } = layerArgs;
99
+ svg.append("svg:circle")
100
+ .attr("id", `${getHtmlId(this.type)}-circle`)
101
+ .attr("cx", "50%")
102
+ .attr("cy", "50%")
103
+ .attr("r", "5%")
104
+ }
105
+ }
106
+
107
+ new Chart()
108
+ .addAxes()
109
+ .addTraces(lines)
110
+ .addScatterPoints(points)
111
+ .addGridLines()
112
+ .addZoom()
113
+ .addTooltips(tooltipHtmlCallback)
114
+ .addCustomLayer(new CustomLayer())
115
+ .addCustomLifecycleHooks({ beforeZoom() { console.log("triggering before zoom") } })
116
+ .makeResponsive()
117
+ .appendTo(chart, scales);
118
+ ```
119
+
120
+ # More details
121
+
122
+ ## Base chart class
123
+
124
+ All charts start with the `Chart` class that takes in `ChartOptions` (e.g. `animationDuration`
125
+ in ms or `logScale`, see [here](./src/Chart.ts) for source code):
126
+
127
+ ```ts
128
+ const chart = new Chart({ animationDuration: 500 });
129
+ ```
130
+
131
+ ## Layers
132
+
133
+ Skadi chart works in layers. Each layer "adds" something to the graph but also is completely
134
+ optional.
135
+
136
+ ### OptionalLayer and event handling
137
+
138
+ An optional layer in Skadi chart is a layer that extends the abstract class
139
+ [OptionalLayer](./src/layers/Layer.ts). This abstract class will expect the layers to define
140
+ a `draw` function that will be used when the layer is added to the svg.
141
+
142
+ Furthermore, it defines the lifecycle hooks a layer can plug into. Lifecycle hooks are how
143
+ the layers react to user events. For example, the layers can each define a `zoom` method that
144
+ the [ZoomLayer](./src/layers/ZoomLayer.ts) will call on each of the layers when the user
145
+ selects an area to zoom into.
146
+
147
+ ### Adding layers
148
+
149
+ To add a ready-made layer to the chart, call one of the methods below. The order of appending
150
+ layers does not matter however currently the multiplicity of layers does matter, i.e if you
151
+ add 2 [AxesLayer](./src/layers/AxesLayer.ts)s it will draw 2 of them which may be unintended.
152
+ These methods can also take some arguments that configure how the layers appear and examples
153
+ of each can be found in [src/demo/App.vue](./src/demo/App.vue). For now, this is just an
154
+ overview of the methods [Chart](./src/Chart.ts) class provides for adding layers:
155
+
156
+ * `addAxes` adds an [AxesLayer](./src/layers/AxesLayer.ts). This will draw axes with tick
157
+ marks. The axes can be autoscaled based on your data or you can provide a fixed scale in
158
+ the `appendTo` function below.
159
+ ```ts
160
+ chart.addAxes();
161
+ ```
162
+ * `addTraces` adds a [TracesLayer](./src/layers/TracesLayer.ts). This will add traces to
163
+ the graph. This data will also be used for autoscaling the axes if you haven't provided a
164
+ fixed scale.
165
+ ```ts
166
+ chart.addTraces(lines);
167
+ ```
168
+ * `addScatterPoints` add a [ScatterLayer](./src/layers/ScatterLayer.ts). This will add
169
+ scatter points to the graph. This data will also be used for autoscaling the axes if you
170
+ haven't provided a fixed scale.
171
+ ```ts
172
+ chart.addScatterPoints(points);
173
+ ```
174
+ * `addGridLines` adds a [GridLayer](./src/layers/GridLayer.ts). This will add gridlines
175
+ to the graph.
176
+ ```ts
177
+ chart.addGridLines();
178
+ ```
179
+ * `addZoom` adds a [ZoomLayer](./src/layers/ZoomLayer.ts) which will render a brush (let
180
+ the user draw a rectangle where they wish to zoom) and provide these extents to each layer.
181
+ Each layer itself defines how it zooms so this will let the user zoom on your graph.
182
+ ```ts
183
+ chart.addZoom();
184
+ ```
185
+ * `addTooltips` adds a [TooltipLayer](./src/layers/TooltipsLayer.ts) which adds tooltips
186
+ to the chart. For traces and points this means the tooltip will appear pointing to the
187
+ closest point in the graph to the cursor (once it is within a threshold). You must provide
188
+ a callback returning HTML to render the tooltip.
189
+ ```ts
190
+ chart.addTooltips(tooltipHtmlCallback);
191
+ ```
192
+ * `makeResponsive` is not really a layer but will make your graph responsive (redraw on change
193
+ to container bounds and changes to window size).
194
+ ```ts
195
+ chart.makeResponsive();
196
+ ```
197
+
198
+ #### Extending Skadi chart with custom layers
199
+
200
+ You can extend Skadi chart's functionality to suit your needs by defining a `CustomLayer`, as
201
+ long as it fulfils the contract of the class `OptionalLayer` found [here](./src/layers/Layer.ts).
202
+ Currently the `OptionalLayer` only requires you to define 2 things:
203
+
204
+ * the `type` of your `CustomLayer` which
205
+ should be `LayerType.Custom` (`LayerType` is an exported enum from the same file) in most cases.
206
+ * the `draw` function which will usually involve creating svg elements
207
+ * the `constructor` which needs to call `super`
208
+
209
+ In the example below, we define our `type` as `LayerType.Custom` and we define `draw` as adding a
210
+ black svg circle onto our graph using [d3.select](https://d3js.org/d3-selection/selecting).
211
+
212
+ We also declare a `beforeZoom` function to print a message before the zoom occurs. This is a
213
+ lifecycle hook that we can use to interact with the hook layer. For all the lifecycle hooks see
214
+ [here](./src/layers/Layer.ts).
215
+
216
+ ```ts
217
+ class CustomLayer extends OptionalLayer {
218
+ type = LayerType.Custom;
219
+ constructor() { super() };
220
+
221
+ // adds a black circle to the svg
222
+ draw(layerArgs: LayerArgs): void {
223
+ const svg = layerArgs.coreLayers[LayerType.Svg];
224
+ const { getHtmlId } = layerArgs;
225
+ svg.append("svg:circle")
226
+ .attr("id", `${getHtmlId(this.type)}-circle`)
227
+ .attr("cx", "50%")
228
+ .attr("cy", "50%")
229
+ .attr("r", "5%")
230
+ }
231
+
232
+ beforeZoom() { console.log("triggering before zoom") }
233
+ }
234
+
235
+ chart.addCustomLayer(new CustomLayer());
236
+ ```
237
+
238
+ If we didn't want to draw anything to the svg and instead wanted to execute some code
239
+ via the lifecycle hooks, the`addCustomLifecycleHooks` offers an easier interface to do
240
+ this. It is a convenience wrapper around `addCustomLayer`.
241
+ ```ts
242
+ chart.addCustomLifecycleHooks({
243
+ beforeZoom() { console.log("triggering before zoom") }
244
+ });
245
+ ```
246
+
247
+ ## Drawing chart with all the layers
248
+
249
+ Once we have added all the layers, we must call `appendTo` function to draw the layers to the
250
+ screen. Without calling this function, nothing will be drawn to the screen. Here we can also
251
+ provide the scales to the graph if we want it to display a fixed scale rather than
252
+ automatically choosing a scale based on your data.
253
+
254
+ ```ts
255
+ chart.appendTo(element, scales);
256
+ ```
257
+
258
+ ## Reactivity
259
+
260
+ There is some reactivity baked into Skadi chart via the lifecycle hooks, such as zooming. In
261
+ general however there is very little reactivity that Skadi chart offers, e.g. there are not
262
+ any functions that will remove layers after the chart is appended to the DOM.
263
+
264
+ The pattern we use for reactivity outside of the scope of lifecycle hooks is to recreate the
265
+ chart from scratch. The `appendTo` function will remove anything inside the chart `div` and
266
+ append the new `Chart` into it. To see examples of reactivity see [here](./src/demo/App.vue).
@@ -0,0 +1,290 @@
1
+ declare module "d3" {
2
+ import { select } from "d3-selection";
3
+ export { select };
4
+ export { type Axis, axisBottom, axisLeft } from "d3-axis";
5
+ export { line, type Line } from "d3-shape";
6
+ export { create, type BaseType, type Selection, type ClientPointEvent, pointer } from "d3-selection";
7
+ export { brush, type D3BrushEvent } from "d3-brush";
8
+ export { scaleBand, scaleLinear, scaleLog, type NumberValue, type ScaleBand, type ScaleContinuousNumeric } from "d3-scale";
9
+ }
10
+ declare module "types" {
11
+ import { ChartOptions } from "Chart";
12
+ import * as d3 from "d3";
13
+ import { LayerType, OptionalLayer } from "layers/Layer";
14
+ export type AxisType = 'x' | 'y';
15
+ export type XY<T> = Record<AxisType, T>;
16
+ export type Point = XY<number>;
17
+ export type PointWithMetadata<Metadata> = Point & {
18
+ metadata?: Metadata;
19
+ bands?: Partial<XY<string>>;
20
+ };
21
+ export type XYLabel = Partial<XY<string>>;
22
+ export type Bounds = {
23
+ width: number;
24
+ height: number;
25
+ margin: {
26
+ top: number;
27
+ bottom: number;
28
+ left: number;
29
+ right: number;
30
+ };
31
+ };
32
+ export type D3Selection<Element extends d3.BaseType> = d3.Selection<Element, Point, null, undefined>;
33
+ export type AllOptionalLayers = OptionalLayer<any>;
34
+ export type ScaleNumeric = d3.ScaleContinuousNumeric<number, number, never>;
35
+ export type CategoricalScaleConfig = {
36
+ main: d3.ScaleBand<string>;
37
+ bands: Record<string, ScaleNumeric>;
38
+ };
39
+ export type TickConfig = {
40
+ count: number;
41
+ specifier?: string;
42
+ };
43
+ export type LayerArgs = {
44
+ id: string;
45
+ getHtmlId: (layer: LayerType) => string;
46
+ bounds: Bounds;
47
+ globals: {
48
+ animationDuration: number;
49
+ tickConfig: XY<TickConfig>;
50
+ };
51
+ scaleConfig: {
52
+ linearScales: XY<ScaleNumeric>;
53
+ scaleExtents: Scales;
54
+ categoricalScales: Partial<XY<CategoricalScaleConfig>>;
55
+ };
56
+ coreLayers: {
57
+ [LayerType.Svg]: D3Selection<SVGSVGElement>;
58
+ [LayerType.ClipPath]: D3Selection<SVGClipPathElement>;
59
+ [LayerType.BaseLayer]: D3Selection<SVGGElement>;
60
+ };
61
+ optionalLayers: AllOptionalLayers[];
62
+ chartOptions: ChartOptions;
63
+ };
64
+ export type ZoomExtents = XY<[number, number]>;
65
+ export type ZoomProperties = ZoomExtents & {
66
+ eventType: "brush" | "dblclick";
67
+ };
68
+ export type Scales = XY<{
69
+ start: number;
70
+ end: number;
71
+ }>;
72
+ export type PartialScales = Partial<XY<{
73
+ start?: number;
74
+ end?: number;
75
+ }>>;
76
+ export type LineStyle = {
77
+ color?: string;
78
+ opacity?: number;
79
+ strokeWidth?: number;
80
+ strokeDasharray?: string;
81
+ };
82
+ export type LineConfig<Metadata> = {
83
+ points: Point[];
84
+ style: LineStyle;
85
+ metadata?: Metadata;
86
+ bands?: Partial<XY<string>>;
87
+ };
88
+ export type Lines<Metadata> = LineConfig<Metadata>[];
89
+ export type ScatterPointStyle = {
90
+ radius?: number;
91
+ color?: string;
92
+ opacity?: number;
93
+ };
94
+ type ScatterPointConfig<Metadata> = PointWithMetadata<Metadata> & {
95
+ style: ScatterPointStyle;
96
+ };
97
+ export type ScatterPoints<Metadata> = ScatterPointConfig<Metadata>[];
98
+ }
99
+ declare module "layers/Layer" {
100
+ import { LayerArgs, ZoomExtents, ZoomProperties } from "types";
101
+ export enum LayerType {
102
+ Svg = "svg",
103
+ ClipPath = "clipPath",
104
+ BaseLayer = "baseLayer",
105
+ Axes = "axes",
106
+ Zoom = "zoom",
107
+ Trace = "trace",
108
+ Tooltip = "skadi-chart-tooltip",
109
+ Grid = "grid",
110
+ Scatter = "scatter",
111
+ Custom = "custom"
112
+ }
113
+ export abstract class OptionalLayer<Properties = null> {
114
+ abstract type: LayerType;
115
+ properties: Properties | null;
116
+ constructor();
117
+ abstract draw(layerArgs: LayerArgs, currentExtents: ZoomExtents): void;
118
+ brushStart(): void;
119
+ beforeZoom(_zoomProperties: ZoomProperties): void;
120
+ zoom(_zoomProperties: ZoomProperties): Promise<void>;
121
+ afterZoom(_zoomProperties: ZoomProperties | null): void;
122
+ }
123
+ export type LifecycleHooks = Omit<OptionalLayer, "type" | "properties" | "draw">;
124
+ }
125
+ declare module "layers/AxesLayer" {
126
+ import { LayerType, OptionalLayer } from "layers/Layer";
127
+ import { LayerArgs, XYLabel } from "types";
128
+ export class AxesLayer extends OptionalLayer {
129
+ labels: XYLabel;
130
+ type: LayerType;
131
+ constructor(labels: XYLabel);
132
+ private drawAxis;
133
+ draw: (layerArgs: LayerArgs) => void;
134
+ private drawCategoricalAxis;
135
+ private drawNumericalAxis;
136
+ private drawLinePerpendicularToAxis;
137
+ private axisConfig;
138
+ }
139
+ }
140
+ declare module "helpers" {
141
+ import { LayerArgs, ScaleNumeric, XY } from "types";
142
+ export const numScales: (bands: Partial<XY<string>> | undefined, layerArgs: LayerArgs) => XY<ScaleNumeric>;
143
+ }
144
+ declare module "layers/TracesLayer" {
145
+ import { LayerArgs, Lines, Point, ZoomExtents } from "types";
146
+ import { LayerType, OptionalLayer } from "layers/Layer";
147
+ export type TracesOptions = {
148
+ RDPEpsilon: number | null;
149
+ };
150
+ export const RDPAlgorithm: (linesSC: Point[][], epsilon: number) => Point[][];
151
+ export class TracesLayer<Metadata> extends OptionalLayer {
152
+ linesDC: Lines<Metadata>;
153
+ options: TracesOptions;
154
+ type: LayerType;
155
+ private traces;
156
+ private lowResLinesSC;
157
+ private getNewPoint;
158
+ constructor(linesDC: Lines<Metadata>, options: TracesOptions);
159
+ private customTween;
160
+ private round;
161
+ private getNewSvgPoint;
162
+ private customLineGen;
163
+ private updateLowResLinesSC;
164
+ draw: (layerArgs: LayerArgs, currentExtentsDC: ZoomExtents) => void;
165
+ }
166
+ }
167
+ declare module "layers/ZoomLayer" {
168
+ import { LayerType, OptionalLayer } from "layers/Layer";
169
+ import { D3Selection, LayerArgs } from "types";
170
+ export type ZoomOptions = {
171
+ lockAxis: "x" | "y" | null;
172
+ };
173
+ export class ZoomLayer extends OptionalLayer {
174
+ options: ZoomOptions;
175
+ type: LayerType;
176
+ zooming: boolean;
177
+ selectionMask: D3Selection<SVGRectElement> | null;
178
+ overlay: D3Selection<SVGRectElement> | null;
179
+ constructor(options: ZoomOptions);
180
+ private processSelection;
181
+ private handleZoom;
182
+ private handleBrushEnd;
183
+ private handleBrushMove;
184
+ draw: (layerArgs: LayerArgs) => void;
185
+ }
186
+ }
187
+ declare module "layers/ScatterLayer" {
188
+ import { LayerArgs, ScatterPoints } from "types";
189
+ import { LayerType, OptionalLayer } from "layers/Layer";
190
+ export class ScatterLayer<Metadata> extends OptionalLayer {
191
+ points: ScatterPoints<Metadata>;
192
+ type: LayerType;
193
+ constructor(points: ScatterPoints<Metadata>);
194
+ draw: (layerArgs: LayerArgs) => void;
195
+ }
196
+ }
197
+ declare module "layers/TooltipsLayer" {
198
+ import { LayerType, OptionalLayer } from "layers/Layer";
199
+ import { LayerArgs, PointWithMetadata } from "types";
200
+ export type TooltipHtmlCallback<Metadata> = (pointWithMetadata: PointWithMetadata<Metadata>) => string;
201
+ export class TooltipsLayer<Metadata> extends OptionalLayer {
202
+ tooltipHtmlCallback: TooltipHtmlCallback<Metadata>;
203
+ type: LayerType;
204
+ tooltipRadiusSq: number;
205
+ constructor(tooltipHtmlCallback: TooltipHtmlCallback<Metadata>);
206
+ private getDistanceSq;
207
+ private getDistanceSqSC;
208
+ private convertSCPointToCC;
209
+ private handleMouseMove;
210
+ draw: (layerArgs: LayerArgs) => void;
211
+ }
212
+ }
213
+ declare module "layers/GridLayer" {
214
+ import { LayerArgs } from "types";
215
+ import { LayerType, OptionalLayer } from "layers/Layer";
216
+ export class GridLayer extends OptionalLayer {
217
+ type: LayerType;
218
+ constructor();
219
+ draw: (layerArgs: LayerArgs) => void;
220
+ }
221
+ }
222
+ declare module "Chart" {
223
+ import { TracesOptions } from "layers/TracesLayer";
224
+ import { ZoomOptions } from "layers/ZoomLayer";
225
+ import { TooltipHtmlCallback } from "layers/TooltipsLayer";
226
+ import { AllOptionalLayers, Lines, PartialScales, Scales, ScatterPoints, XY, XYLabel } from "types";
227
+ import { LifecycleHooks, OptionalLayer } from "layers/Layer";
228
+ export type ChartOptions = {
229
+ logScale: XY<boolean>;
230
+ };
231
+ type PartialChartOptions = {
232
+ logScale?: Partial<XY<boolean>>;
233
+ animationDuration?: number;
234
+ };
235
+ type CategoricalScales = Partial<XY<string[]>>;
236
+ export class Chart<Metadata = any> {
237
+ id: string;
238
+ optionalLayers: AllOptionalLayers[];
239
+ isResponsive: boolean;
240
+ globals: {
241
+ animationDuration: number;
242
+ tickConfig: {
243
+ x: {
244
+ count: number;
245
+ };
246
+ y: {
247
+ count: number;
248
+ specifier: string;
249
+ };
250
+ };
251
+ };
252
+ defaultMargin: {
253
+ top: number;
254
+ bottom: number;
255
+ left: number;
256
+ right: number;
257
+ };
258
+ exportToPng: ((name?: string) => void) | null;
259
+ options: ChartOptions;
260
+ autoscaledMaxExtents: Scales;
261
+ constructor(options?: PartialChartOptions);
262
+ addAxes: (labels?: XYLabel) => this;
263
+ addGridLines: () => this;
264
+ private filterLinesForLogAxis;
265
+ private filterLines;
266
+ addTraces: (lines: Lines<Metadata>, options?: Partial<TracesOptions>) => this;
267
+ addZoom: (options?: ZoomOptions) => this;
268
+ addTooltips: (tooltipHtmlCallback: TooltipHtmlCallback<Metadata>) => this;
269
+ private filterScatterPointsForLogAxis;
270
+ private filterScatterPoints;
271
+ addScatterPoints: (points: ScatterPoints<Metadata>) => this;
272
+ addCustomLifecycleHooks: (lifecycleHooks: Partial<LifecycleHooks>) => this;
273
+ addCustomLayer: (customLayer: OptionalLayer) => this;
274
+ makeResponsive: () => this;
275
+ private getXYMinMax;
276
+ private addLinearPadding;
277
+ private addLogPadding;
278
+ private processScales;
279
+ private draw;
280
+ appendTo: (baseElement: HTMLDivElement, maxExtents?: PartialScales, initialExtents?: PartialScales, categoricalScales?: CategoricalScales) => this;
281
+ private createCategoricalScale;
282
+ }
283
+ }
284
+ declare module "skadi-chart" {
285
+ import { Chart, ChartOptions } from "Chart";
286
+ import { Lines, Scales, ZoomExtents, ZoomProperties, LayerArgs, ScatterPoints, LineStyle, ScatterPointStyle, Point } from "types";
287
+ import { OptionalLayer, LayerType } from "layers/Layer";
288
+ export { Chart, OptionalLayer, LayerType };
289
+ export type { Lines, Scales, ZoomExtents, ZoomProperties, LayerArgs, ScatterPoints, LineStyle, ScatterPointStyle, Point, ChartOptions };
290
+ }