@trebco/treb 23.6.5 → 25.0.0-rc1
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/.eslintignore +8 -0
- package/.eslintrc.js +164 -0
- package/README-shadow-DOM.md +88 -0
- package/README.md +37 -130
- package/api-config.json +29 -0
- package/api-generator/api-generator-types.ts +82 -0
- package/api-generator/api-generator.ts +1172 -0
- package/api-generator/package.json +3 -0
- package/build/treb-spreadsheet.mjs +14 -0
- package/{treb.d.ts → build/treb.d.ts} +285 -269
- package/esbuild-custom-element.mjs +336 -0
- package/esbuild.js +305 -0
- package/package.json +43 -14
- package/treb-base-types/package.json +5 -0
- package/treb-base-types/src/api_types.ts +36 -0
- package/treb-base-types/src/area.ts +583 -0
- package/treb-base-types/src/basic_types.ts +45 -0
- package/treb-base-types/src/cell.ts +612 -0
- package/treb-base-types/src/cells.ts +1066 -0
- package/treb-base-types/src/color.ts +124 -0
- package/treb-base-types/src/import.ts +71 -0
- package/treb-base-types/src/index-standalone.ts +29 -0
- package/treb-base-types/src/index.ts +42 -0
- package/treb-base-types/src/layout.ts +47 -0
- package/treb-base-types/src/localization.ts +187 -0
- package/treb-base-types/src/rectangle.ts +145 -0
- package/treb-base-types/src/render_text.ts +72 -0
- package/treb-base-types/src/style.ts +545 -0
- package/treb-base-types/src/table.ts +109 -0
- package/treb-base-types/src/text_part.ts +54 -0
- package/treb-base-types/src/theme.ts +608 -0
- package/treb-base-types/src/union.ts +152 -0
- package/treb-base-types/src/value-type.ts +164 -0
- package/treb-base-types/style/resizable.css +59 -0
- package/treb-calculator/modern.tsconfig.json +11 -0
- package/treb-calculator/package.json +5 -0
- package/treb-calculator/src/calculator.ts +2546 -0
- package/treb-calculator/src/complex-math.ts +558 -0
- package/treb-calculator/src/dag/array-vertex.ts +198 -0
- package/treb-calculator/src/dag/graph.ts +951 -0
- package/treb-calculator/src/dag/leaf_vertex.ts +118 -0
- package/treb-calculator/src/dag/spreadsheet_vertex.ts +327 -0
- package/treb-calculator/src/dag/spreadsheet_vertex_base.ts +44 -0
- package/treb-calculator/src/dag/vertex.ts +352 -0
- package/treb-calculator/src/descriptors.ts +162 -0
- package/treb-calculator/src/expression-calculator.ts +1069 -0
- package/treb-calculator/src/function-error.ts +103 -0
- package/treb-calculator/src/function-library.ts +103 -0
- package/treb-calculator/src/functions/base-functions.ts +1214 -0
- package/treb-calculator/src/functions/checkbox.ts +164 -0
- package/treb-calculator/src/functions/complex-functions.ts +253 -0
- package/treb-calculator/src/functions/finance-functions.ts +399 -0
- package/treb-calculator/src/functions/information-functions.ts +102 -0
- package/treb-calculator/src/functions/matrix-functions.ts +182 -0
- package/treb-calculator/src/functions/sparkline.ts +335 -0
- package/treb-calculator/src/functions/statistics-functions.ts +350 -0
- package/treb-calculator/src/functions/text-functions.ts +298 -0
- package/treb-calculator/src/index.ts +27 -0
- package/treb-calculator/src/notifier-types.ts +59 -0
- package/treb-calculator/src/primitives.ts +428 -0
- package/treb-calculator/src/utilities.ts +305 -0
- package/treb-charts/package.json +5 -0
- package/treb-charts/src/chart-functions.ts +156 -0
- package/treb-charts/src/chart-types.ts +230 -0
- package/treb-charts/src/chart.ts +1288 -0
- package/treb-charts/src/index.ts +24 -0
- package/treb-charts/src/main.ts +37 -0
- package/treb-charts/src/rectangle.ts +52 -0
- package/treb-charts/src/renderer.ts +1841 -0
- package/treb-charts/src/util.ts +122 -0
- package/treb-charts/style/charts.scss +221 -0
- package/treb-charts/style/old-charts.scss +250 -0
- package/treb-embed/markup/layout.html +137 -0
- package/treb-embed/markup/toolbar.html +175 -0
- package/treb-embed/modern.tsconfig.json +25 -0
- package/treb-embed/src/custom-element/content-types.d.ts +18 -0
- package/treb-embed/src/custom-element/global.d.ts +11 -0
- package/treb-embed/src/custom-element/spreadsheet-constructor.ts +1227 -0
- package/treb-embed/src/custom-element/treb-global.ts +44 -0
- package/treb-embed/src/custom-element/treb-spreadsheet-element.ts +52 -0
- package/treb-embed/src/embedded-spreadsheet.ts +5362 -0
- package/treb-embed/src/index.ts +16 -0
- package/treb-embed/src/language-model.ts +41 -0
- package/treb-embed/src/options.ts +320 -0
- package/treb-embed/src/progress-dialog.ts +228 -0
- package/treb-embed/src/selection-state.ts +16 -0
- package/treb-embed/src/spinner.ts +42 -0
- package/treb-embed/src/toolbar-message.ts +96 -0
- package/treb-embed/src/types.ts +167 -0
- package/treb-embed/style/autocomplete.scss +103 -0
- package/treb-embed/style/dark-theme.scss +114 -0
- package/treb-embed/style/defaults.scss +36 -0
- package/treb-embed/style/dialog.scss +181 -0
- package/treb-embed/style/dropdown-select.scss +101 -0
- package/treb-embed/style/formula-bar.scss +193 -0
- package/treb-embed/style/grid.scss +374 -0
- package/treb-embed/style/layout.scss +424 -0
- package/treb-embed/style/mouse-mask.scss +67 -0
- package/treb-embed/style/note.scss +92 -0
- package/treb-embed/style/overlay-editor.scss +102 -0
- package/treb-embed/style/spinner.scss +92 -0
- package/treb-embed/style/tab-bar.scss +228 -0
- package/treb-embed/style/table.scss +80 -0
- package/treb-embed/style/theme-defaults.scss +444 -0
- package/treb-embed/style/toolbar.scss +416 -0
- package/treb-embed/style/tooltip.scss +68 -0
- package/treb-embed/style/treb-icons.scss +130 -0
- package/treb-embed/style/treb-spreadsheet-element.scss +20 -0
- package/treb-embed/style/z-index.scss +43 -0
- package/treb-export/docs/charts.md +68 -0
- package/treb-export/modern.tsconfig.json +19 -0
- package/treb-export/package.json +4 -0
- package/treb-export/src/address-type.ts +77 -0
- package/treb-export/src/base-template.ts +22 -0
- package/treb-export/src/column-width.ts +85 -0
- package/treb-export/src/drawing2/chart-template-components2.ts +389 -0
- package/treb-export/src/drawing2/chart2.ts +282 -0
- package/treb-export/src/drawing2/column-chart-template2.ts +521 -0
- package/treb-export/src/drawing2/donut-chart-template2.ts +296 -0
- package/treb-export/src/drawing2/drawing2.ts +355 -0
- package/treb-export/src/drawing2/embedded-image.ts +71 -0
- package/treb-export/src/drawing2/scatter-chart-template2.ts +555 -0
- package/treb-export/src/export-worker/export-worker.ts +99 -0
- package/treb-export/src/export-worker/index-modern.ts +22 -0
- package/treb-export/src/export2.ts +2204 -0
- package/treb-export/src/import2.ts +882 -0
- package/treb-export/src/relationship.ts +36 -0
- package/treb-export/src/shared-strings2.ts +128 -0
- package/treb-export/src/template-2.ts +22 -0
- package/treb-export/src/unescape_xml.ts +47 -0
- package/treb-export/src/workbook-sheet2.ts +182 -0
- package/treb-export/src/workbook-style2.ts +1285 -0
- package/treb-export/src/workbook-theme2.ts +88 -0
- package/treb-export/src/workbook2.ts +491 -0
- package/treb-export/src/xml-utils.ts +201 -0
- package/treb-export/template/base/[Content_Types].xml +2 -0
- package/treb-export/template/base/_rels/.rels +2 -0
- package/treb-export/template/base/docProps/app.xml +2 -0
- package/treb-export/template/base/docProps/core.xml +12 -0
- package/treb-export/template/base/xl/_rels/workbook.xml.rels +2 -0
- package/treb-export/template/base/xl/sharedStrings.xml +2 -0
- package/treb-export/template/base/xl/styles.xml +2 -0
- package/treb-export/template/base/xl/theme/theme1.xml +2 -0
- package/treb-export/template/base/xl/workbook.xml +2 -0
- package/treb-export/template/base/xl/worksheets/sheet1.xml +2 -0
- package/treb-export/template/base.xlsx +0 -0
- package/treb-format/package.json +8 -0
- package/treb-format/src/format.test.ts +213 -0
- package/treb-format/src/format.ts +942 -0
- package/treb-format/src/format_cache.ts +199 -0
- package/treb-format/src/format_parser.ts +723 -0
- package/treb-format/src/index.ts +25 -0
- package/treb-format/src/number_format_section.ts +100 -0
- package/treb-format/src/value_parser.ts +337 -0
- package/treb-grid/package.json +5 -0
- package/treb-grid/src/editors/autocomplete.ts +394 -0
- package/treb-grid/src/editors/autocomplete_matcher.ts +260 -0
- package/treb-grid/src/editors/formula_bar.ts +473 -0
- package/treb-grid/src/editors/formula_editor_base.ts +910 -0
- package/treb-grid/src/editors/overlay_editor.ts +511 -0
- package/treb-grid/src/index.ts +37 -0
- package/treb-grid/src/layout/base_layout.ts +2618 -0
- package/treb-grid/src/layout/grid_layout.ts +299 -0
- package/treb-grid/src/layout/rectangle_cache.ts +86 -0
- package/treb-grid/src/render/selection-renderer.ts +414 -0
- package/treb-grid/src/render/svg_header_overlay.ts +93 -0
- package/treb-grid/src/render/svg_selection_block.ts +187 -0
- package/treb-grid/src/render/tile_renderer.ts +2122 -0
- package/treb-grid/src/types/annotation.ts +216 -0
- package/treb-grid/src/types/border_constants.ts +34 -0
- package/treb-grid/src/types/clipboard_data.ts +31 -0
- package/treb-grid/src/types/data_model.ts +334 -0
- package/treb-grid/src/types/drag_mask.ts +81 -0
- package/treb-grid/src/types/grid.ts +7743 -0
- package/treb-grid/src/types/grid_base.ts +3644 -0
- package/treb-grid/src/types/grid_command.ts +470 -0
- package/treb-grid/src/types/grid_events.ts +124 -0
- package/treb-grid/src/types/grid_options.ts +97 -0
- package/treb-grid/src/types/grid_selection.ts +60 -0
- package/treb-grid/src/types/named_range.ts +369 -0
- package/treb-grid/src/types/scale-control.ts +202 -0
- package/treb-grid/src/types/serialize_options.ts +72 -0
- package/treb-grid/src/types/set_range_options.ts +52 -0
- package/treb-grid/src/types/sheet.ts +3099 -0
- package/treb-grid/src/types/sheet_types.ts +95 -0
- package/treb-grid/src/types/tab_bar.ts +464 -0
- package/treb-grid/src/types/tile.ts +59 -0
- package/treb-grid/src/types/update_flags.ts +75 -0
- package/treb-grid/src/util/dom_utilities.ts +44 -0
- package/treb-grid/src/util/fontmetrics2.ts +179 -0
- package/treb-grid/src/util/ua.ts +104 -0
- package/treb-logo.svg +18 -0
- package/treb-parser/package.json +5 -0
- package/treb-parser/src/csv-parser.ts +122 -0
- package/treb-parser/src/index.ts +25 -0
- package/treb-parser/src/md-parser.ts +526 -0
- package/treb-parser/src/parser-types.ts +397 -0
- package/treb-parser/src/parser.test.ts +298 -0
- package/treb-parser/src/parser.ts +2673 -0
- package/treb-utils/package.json +5 -0
- package/treb-utils/src/dispatch.ts +57 -0
- package/treb-utils/src/event_source.ts +147 -0
- package/treb-utils/src/ievent_source.ts +33 -0
- package/treb-utils/src/index.ts +31 -0
- package/treb-utils/src/measurement.ts +174 -0
- package/treb-utils/src/resizable.ts +160 -0
- package/treb-utils/src/scale.ts +137 -0
- package/treb-utils/src/serialize_html.ts +124 -0
- package/treb-utils/src/template.ts +70 -0
- package/treb-utils/src/validate_uri.ts +61 -0
- package/tsconfig.json +10 -0
- package/tsproject.json +30 -0
- package/util/license-plugin-esbuild.js +86 -0
- package/util/list-css-vars.sh +46 -0
- package/README-esm.md +0 -37
- package/treb-bundle.css +0 -2
- package/treb-bundle.mjs +0 -15
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of TREB.
|
|
3
|
+
*
|
|
4
|
+
* TREB is free software: you can redistribute it and/or modify it under the
|
|
5
|
+
* terms of the GNU General Public License as published by the Free Software
|
|
6
|
+
* Foundation, either version 3 of the License, or (at your option) any
|
|
7
|
+
* later version.
|
|
8
|
+
*
|
|
9
|
+
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
10
|
+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
11
|
+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
12
|
+
* details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License along
|
|
15
|
+
* with TREB. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*
|
|
17
|
+
* Copyright 2022-2023 trebco, llc.
|
|
18
|
+
* info@treb.app
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Area, Size, Point } from './rectangle';
|
|
23
|
+
import { DonutSlice, LegendLayout, LegendOptions, LegendPosition, LegendStyle } from './chart-types';
|
|
24
|
+
import type { RangeScale } from 'treb-utils';
|
|
25
|
+
|
|
26
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
27
|
+
|
|
28
|
+
export interface Metrics {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
y_offset: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// const trident = /trident/i.test(navigator?.userAgent || '');
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
let dom_parser: DOMParser | undefined;
|
|
38
|
+
const SetSVG = trident ? (node: SVGElement, svg: string) => {
|
|
39
|
+
|
|
40
|
+
if (!dom_parser) {
|
|
41
|
+
dom_parser = new DOMParser();
|
|
42
|
+
(dom_parser as any).async = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const element = dom_parser.parseFromString(
|
|
46
|
+
'<svg xmlns=\'http://www.w3.org/2000/svg\' xmlns:xlink=\'http://www.w3.org/1999/xlink\'>' + svg + '</svg>',
|
|
47
|
+
'text/xml').documentElement;
|
|
48
|
+
|
|
49
|
+
node.textContent = '';
|
|
50
|
+
|
|
51
|
+
let child = element.firstChild;
|
|
52
|
+
|
|
53
|
+
while (child) {
|
|
54
|
+
node.appendChild(document.importNode(child, true));
|
|
55
|
+
child = child.nextSibling;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
} : (node: SVGElement, svg: string) => node.innerHTML = svg;
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const SVGNode = (tag: string, attribute_map: {[index: string]: any} = {}, text?: string): SVGElement => {
|
|
62
|
+
const node = document.createElementNS(SVGNS, tag);
|
|
63
|
+
for (const key of Object.keys(attribute_map)) {
|
|
64
|
+
if (attribute_map[key] !== undefined) {
|
|
65
|
+
const value = attribute_map[key];
|
|
66
|
+
node.setAttribute(key, Array.isArray(value) ? value.join(' ') : value.toString());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (text) { node.textContent = text; }
|
|
70
|
+
return node;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* FIXME: normalize API, make canvas version
|
|
75
|
+
*/
|
|
76
|
+
export class ChartRenderer {
|
|
77
|
+
|
|
78
|
+
public parent!: HTMLElement;
|
|
79
|
+
public svg_node!: SVGElement;
|
|
80
|
+
public text_measurement_node?: SVGTextElement;
|
|
81
|
+
|
|
82
|
+
public container_group: SVGGElement;
|
|
83
|
+
public group: SVGGElement;
|
|
84
|
+
public axis_group: SVGGElement;
|
|
85
|
+
public label_group: SVGGElement;
|
|
86
|
+
|
|
87
|
+
public size: Size = { width: 0, height: 0 };
|
|
88
|
+
public bounds: Area = new Area();
|
|
89
|
+
|
|
90
|
+
// public smoothing_factor = 0.2;
|
|
91
|
+
|
|
92
|
+
constructor() {
|
|
93
|
+
this.container_group = SVGNode('g') as SVGGElement;
|
|
94
|
+
|
|
95
|
+
this.group = SVGNode('g') as SVGGElement;
|
|
96
|
+
this.axis_group = SVGNode('g', {class: 'axis-group'}) as SVGGElement;
|
|
97
|
+
this.label_group = SVGNode('g', {class: 'label-group'}) as SVGGElement;
|
|
98
|
+
|
|
99
|
+
this.container_group.appendChild(this.axis_group);
|
|
100
|
+
this.container_group.appendChild(this.group);
|
|
101
|
+
this.container_group.appendChild(this.label_group);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public Initialize(node: HTMLElement): void {
|
|
105
|
+
this.parent = node;
|
|
106
|
+
|
|
107
|
+
this.svg_node = SVGNode('svg', {
|
|
108
|
+
class: 'treb-chart',
|
|
109
|
+
// style: 'overflow: hidden; position: relative; width: 100%; height: 100%;'
|
|
110
|
+
});
|
|
111
|
+
this.svg_node.style.overflow = 'hidden';
|
|
112
|
+
this.svg_node.style.position = 'relative';
|
|
113
|
+
this.svg_node.style.width = '100%';
|
|
114
|
+
this.svg_node.style.height = '100%';
|
|
115
|
+
|
|
116
|
+
// this.group = document.createElementNS(SVGNS, 'g');
|
|
117
|
+
this.svg_node.appendChild(this.container_group);
|
|
118
|
+
|
|
119
|
+
// FIXME: validate parent is relative/absolute
|
|
120
|
+
|
|
121
|
+
this.parent.appendChild(this.svg_node);
|
|
122
|
+
this.Resize();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public Legend(options: LegendOptions): void {
|
|
126
|
+
const group = SVGNode('g');
|
|
127
|
+
this.group.appendChild(group);
|
|
128
|
+
|
|
129
|
+
const measure = SVGNode('text');
|
|
130
|
+
group.appendChild(measure);
|
|
131
|
+
|
|
132
|
+
// IE says no
|
|
133
|
+
// group.classList.add('legend');
|
|
134
|
+
group.setAttribute('class', 'legend');
|
|
135
|
+
|
|
136
|
+
const rows: number[][] = [[]];
|
|
137
|
+
const padding = 10;
|
|
138
|
+
let space = options.area.width;
|
|
139
|
+
let row = 0;
|
|
140
|
+
let max_height = 0;
|
|
141
|
+
const width = options.area.width;
|
|
142
|
+
|
|
143
|
+
const marker_width = (options.style === LegendStyle.marker) ? 14 : 26;
|
|
144
|
+
|
|
145
|
+
const metrics = options.labels.map((label, index) => {
|
|
146
|
+
measure.textContent = label.label;
|
|
147
|
+
|
|
148
|
+
const text_rect = measure.getBoundingClientRect();
|
|
149
|
+
const text_metrics = { width: text_rect.width, height: text_rect.height };
|
|
150
|
+
const composite = text_metrics.width + marker_width + padding;
|
|
151
|
+
|
|
152
|
+
max_height = Math.max(max_height, text_metrics.height);
|
|
153
|
+
|
|
154
|
+
if (options.layout === LegendLayout.vertical) {
|
|
155
|
+
rows[index] = [index];
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
if (composite > space) {
|
|
159
|
+
if (rows[row].length === 0) {
|
|
160
|
+
|
|
161
|
+
// there's nothing in this row, so moving to the next
|
|
162
|
+
// row will not help; stick it in here regardless
|
|
163
|
+
|
|
164
|
+
rows[row].push(index);
|
|
165
|
+
row++;
|
|
166
|
+
rows[row] = [];
|
|
167
|
+
space = width;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
row++;
|
|
171
|
+
rows[row] = [index];
|
|
172
|
+
space = width - composite;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
rows[row].push(index);
|
|
177
|
+
space -= composite;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return text_metrics;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// IE11: SVG element doesn't have parent element? (...)
|
|
185
|
+
|
|
186
|
+
// measure.parentElement?.removeChild(measure);
|
|
187
|
+
group.removeChild(measure);
|
|
188
|
+
|
|
189
|
+
let y = max_height;
|
|
190
|
+
|
|
191
|
+
let layout = options.layout || LegendLayout.horizontal;
|
|
192
|
+
if (layout === LegendLayout.horizontal && rows.every(row => row.length <= 1)) {
|
|
193
|
+
layout = LegendLayout.horizontal;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (let row = 0; row < rows.length; row++) {
|
|
197
|
+
|
|
198
|
+
const row_width = rows[row].reduce((a, x) => a + metrics[x].width + marker_width, (rows[row].length - 1) * padding);
|
|
199
|
+
|
|
200
|
+
let h = 0;
|
|
201
|
+
let x = layout === LegendLayout.horizontal ?
|
|
202
|
+
Math.round((width - row_width) / 2) :
|
|
203
|
+
Math.round(padding / 2);
|
|
204
|
+
|
|
205
|
+
for (let col = 0; col < rows[row].length; col++) {
|
|
206
|
+
|
|
207
|
+
const index = rows[row][col];
|
|
208
|
+
const text_metrrics = metrics[index];
|
|
209
|
+
const label = options.labels[index];
|
|
210
|
+
|
|
211
|
+
const marker_y = y - 1; // Math.round(y + text_metrrics.height / 2);
|
|
212
|
+
|
|
213
|
+
// NOTE: trident offset is inlined here
|
|
214
|
+
|
|
215
|
+
let trident = false;
|
|
216
|
+
if (typeof navigator !== 'undefined') {
|
|
217
|
+
trident = /trident/i.test(navigator?.userAgent || '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const color = typeof label.index === 'number' ? label.index : index + 1;
|
|
221
|
+
|
|
222
|
+
group.appendChild(SVGNode('text', {
|
|
223
|
+
'dominant-baseline': 'middle', x: x + marker_width, y, dy: (trident ? '.3em' : undefined) }, label.label));
|
|
224
|
+
|
|
225
|
+
if (options.style === LegendStyle.marker) {
|
|
226
|
+
group.appendChild(SVGNode('rect', {
|
|
227
|
+
class: `series-${color}`, x, y: marker_y - 4, width: 8, height: 8 }));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
group.appendChild(SVGNode('rect', {
|
|
231
|
+
class: `series-${color}`, x, y: marker_y - 1, width: marker_width - 3, height: 2}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
h = Math.max(h, text_metrrics.height);
|
|
235
|
+
x += text_metrrics.width + marker_width + padding;
|
|
236
|
+
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
y = Math.round(y + h * 1.1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const rect = group.getBoundingClientRect();
|
|
243
|
+
const legend_size = { width: rect.width, height: rect.height + max_height };
|
|
244
|
+
|
|
245
|
+
switch (options.position) {
|
|
246
|
+
case LegendPosition.bottom:
|
|
247
|
+
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.bottom - legend_size.height})`);
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case LegendPosition.left:
|
|
251
|
+
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.top})`);
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case LegendPosition.right:
|
|
255
|
+
group.setAttribute('transform', `translate(${options.area.right - legend_size.width}, ${options.area.top})`);
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case LegendPosition.top:
|
|
259
|
+
default:
|
|
260
|
+
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.top})`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (options.position === LegendPosition.top) {
|
|
264
|
+
options.area.top += legend_size.height || 0;
|
|
265
|
+
}
|
|
266
|
+
else if (options.position === LegendPosition.right) {
|
|
267
|
+
options.area.right -= ((legend_size.width || 0) + 8); // 8?
|
|
268
|
+
}
|
|
269
|
+
else if (options.position === LegendPosition.left) {
|
|
270
|
+
options.area.left += ((legend_size.width || 0) + 8);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
options.area.bottom -= legend_size.height || 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// return legend_size;
|
|
277
|
+
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
public Clear(class_name?: string): void {
|
|
281
|
+
this.group.textContent = '';
|
|
282
|
+
this.axis_group.textContent = '';
|
|
283
|
+
this.label_group.textContent = '';
|
|
284
|
+
class_name = 'treb-chart' + (class_name ? ' ' + class_name: '');
|
|
285
|
+
this.svg_node.setAttribute('class', class_name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public Resize(): void {
|
|
289
|
+
const bounds = this.parent.getBoundingClientRect();
|
|
290
|
+
this.svg_node.setAttribute('width', bounds.width.toString());
|
|
291
|
+
this.svg_node.setAttribute('height', bounds.height.toString());
|
|
292
|
+
this.size = {
|
|
293
|
+
width: bounds.width,
|
|
294
|
+
height: bounds.height,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* initialize before render. this assumes that document layout/scroll
|
|
300
|
+
* won't change during the render pass, so we can cache some values.
|
|
301
|
+
*/
|
|
302
|
+
public Prerender(): void {
|
|
303
|
+
const bounds = this.svg_node.getBoundingClientRect();
|
|
304
|
+
this.bounds.top = bounds.top;
|
|
305
|
+
this.bounds.left = bounds.left;
|
|
306
|
+
this.bounds.right = bounds.right;
|
|
307
|
+
this.bounds.bottom = bounds.bottom;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* render title. this method modifies "area" in place -- that's
|
|
312
|
+
* the style we want to use going forward.
|
|
313
|
+
*
|
|
314
|
+
* @param title
|
|
315
|
+
* @param area
|
|
316
|
+
* @param margin
|
|
317
|
+
* @param layout
|
|
318
|
+
*/
|
|
319
|
+
public RenderTitle(
|
|
320
|
+
title: string,
|
|
321
|
+
area: Area,
|
|
322
|
+
margin: number,
|
|
323
|
+
layout: 'top'|'bottom'): void {
|
|
324
|
+
|
|
325
|
+
const text = SVGNode('text', {
|
|
326
|
+
class: 'chart-title',
|
|
327
|
+
x: Math.round(area.width / 2),
|
|
328
|
+
// style: 'text-anchor: middle',
|
|
329
|
+
}, title);
|
|
330
|
+
text.style.textAnchor = 'middle';
|
|
331
|
+
|
|
332
|
+
this.group.appendChild(text);
|
|
333
|
+
const bounds = text.getBoundingClientRect();
|
|
334
|
+
|
|
335
|
+
switch (layout) {
|
|
336
|
+
case 'bottom':
|
|
337
|
+
text.setAttribute('y', Math.round(area.bottom - bounds.height).toString());
|
|
338
|
+
area.bottom -= (bounds.height + margin);
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
default:
|
|
342
|
+
text.setAttribute('y', Math.round(area.top + margin + bounds.height).toString());
|
|
343
|
+
area.top += (bounds.height + margin);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* measure a label, optionally with class name(s)
|
|
351
|
+
*
|
|
352
|
+
* this is silly. you are doing the measurement on a random node and
|
|
353
|
+
* trying to match classes, while you could just do the measurement on
|
|
354
|
+
* the actual node, get actual classes right, and not bother with junk
|
|
355
|
+
* nodes.
|
|
356
|
+
*
|
|
357
|
+
* FIXME: decprecate
|
|
358
|
+
*
|
|
359
|
+
*/
|
|
360
|
+
public MeasureText(label: string, classes?: string | string[], ceil = false): Metrics {
|
|
361
|
+
|
|
362
|
+
if (!this.text_measurement_node) {
|
|
363
|
+
this.text_measurement_node = SVGNode('text', { x: '-100px', y: '-100px' }) as SVGTextElement;
|
|
364
|
+
this.svg_node.appendChild(this.text_measurement_node);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (typeof classes !== 'undefined') {
|
|
368
|
+
if (typeof classes === 'string') {
|
|
369
|
+
classes = [classes];
|
|
370
|
+
}
|
|
371
|
+
this.text_measurement_node.setAttribute('class', classes.join(' '));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
this.text_measurement_node.setAttribute('class', '');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.text_measurement_node.textContent = label;
|
|
378
|
+
|
|
379
|
+
const bounds = this.text_measurement_node.getBoundingClientRect();
|
|
380
|
+
|
|
381
|
+
const metrics = {
|
|
382
|
+
width: bounds.width,
|
|
383
|
+
height: bounds.height,
|
|
384
|
+
|
|
385
|
+
// wtf is this?
|
|
386
|
+
y_offset: bounds.height - ((this.bounds.top - bounds.top) - 100),
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (ceil) {
|
|
390
|
+
metrics.width = Math.ceil(metrics.width);
|
|
391
|
+
metrics.height = Math.ceil(metrics.height);
|
|
392
|
+
metrics.y_offset = Math.ceil(metrics.y_offset);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return metrics;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public RenderTicks(area: Area,
|
|
399
|
+
top: number, bottom: number, count: number, classes?: string | string[]) {
|
|
400
|
+
|
|
401
|
+
const d: string[] = [];
|
|
402
|
+
|
|
403
|
+
const step = area.width / (count);
|
|
404
|
+
for (let i = 0; i < count; i++) {
|
|
405
|
+
const center = Math.round(area.left + step / 2 + step * i) - 0.5;
|
|
406
|
+
d.push(`M${center} ${top} L${center} ${bottom}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
this.group.appendChild(SVGNode('path', {d, class: classes}));
|
|
410
|
+
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
public GetAxisNode(): SVGElement {
|
|
415
|
+
if (!this.axis_group) {
|
|
416
|
+
this.axis_group = SVGNode('g', {class: 'axis-group'});
|
|
417
|
+
this.group.appendChild(this.axis_group);
|
|
418
|
+
}
|
|
419
|
+
return this.axis_group;
|
|
420
|
+
}
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
/** specialization for bar; it's different enough that we want special treatment */
|
|
424
|
+
public RenderXAxisBar(
|
|
425
|
+
area: Area,
|
|
426
|
+
offset: boolean,
|
|
427
|
+
labels: string[],
|
|
428
|
+
metrics: Metrics[],
|
|
429
|
+
classes?: string | string[]): void {
|
|
430
|
+
|
|
431
|
+
const count = labels.length;
|
|
432
|
+
if (!count) return;
|
|
433
|
+
|
|
434
|
+
// FIXME: base on font, ' ' character
|
|
435
|
+
const label_buffer = 4;
|
|
436
|
+
|
|
437
|
+
const step = offset ? area.width / count : area.width / (count - 1);
|
|
438
|
+
const initial_offset = offset ? (step / 2) : 0;
|
|
439
|
+
|
|
440
|
+
// calculate increment (skip_count)
|
|
441
|
+
let increment = 1;
|
|
442
|
+
let repeat = true;
|
|
443
|
+
|
|
444
|
+
while (repeat) {
|
|
445
|
+
repeat = false;
|
|
446
|
+
let extent = 0;
|
|
447
|
+
for (let i = 0; i < count; i += increment) {
|
|
448
|
+
const center = Math.round(area.left + initial_offset + step * i);
|
|
449
|
+
const left = center - metrics[i].width / 2;
|
|
450
|
+
if (extent && (left <= extent)) {
|
|
451
|
+
increment++;
|
|
452
|
+
repeat = true;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// FIXME: buffer? they get pretty tight sometimes
|
|
457
|
+
|
|
458
|
+
extent = center + (metrics[i].width / 2) + label_buffer;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// const axis = this.GetAxisNode();
|
|
463
|
+
|
|
464
|
+
for (let i = 0; i < count; i += increment) {
|
|
465
|
+
const x = Math.round(area.left + initial_offset + step * i);
|
|
466
|
+
// if (x + metrics[i].width / 2 >= area.right) { break; }
|
|
467
|
+
this.RenderText(this.axis_group, labels[i], 'center', { x, y: area.bottom }, classes);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
public RenderXAxisTicks(area: Area, offset: boolean, count: number): void {
|
|
473
|
+
|
|
474
|
+
const step = offset ? area.width / count : area.width / (count - 1);
|
|
475
|
+
const initial_offset = offset ? (step / 2) : 0;
|
|
476
|
+
|
|
477
|
+
const d: string[] = [];
|
|
478
|
+
for (let i = 0; i < count; i++) {
|
|
479
|
+
const center = Math.round(area.left + initial_offset + step * i) + .5;
|
|
480
|
+
d.push(`M${center},${area.bottom + .5} v${6}`)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
this.axis_group.appendChild(SVGNode('path', {d: d.join(' '), class: 'x-axis-tick axis-tick'}));
|
|
484
|
+
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* render x axis labels; skips over labels to prevent overlap
|
|
489
|
+
*
|
|
490
|
+
* @param offset - move label by 1/2 step width, to center it under columns.
|
|
491
|
+
*/
|
|
492
|
+
public RenderXAxis(
|
|
493
|
+
area: Area,
|
|
494
|
+
offset: boolean,
|
|
495
|
+
labels: string[],
|
|
496
|
+
metrics: Metrics[],
|
|
497
|
+
classes?: string | string[]): void {
|
|
498
|
+
|
|
499
|
+
const count = labels.length;
|
|
500
|
+
if (!count) return;
|
|
501
|
+
|
|
502
|
+
// FIXME: base on font, ' ' character
|
|
503
|
+
const label_buffer = 4;
|
|
504
|
+
|
|
505
|
+
const step = offset ? area.width / count : area.width / (count - 1);
|
|
506
|
+
// const initial_offset = shift ? (step / 2) : 0;
|
|
507
|
+
const initial_offset = offset ? (step / 2) : 0;
|
|
508
|
+
|
|
509
|
+
// calculate increment (skip_count)
|
|
510
|
+
let increment = 1;
|
|
511
|
+
let repeat = true;
|
|
512
|
+
|
|
513
|
+
const f2 = (labels.length - 1) % 2 === 0;
|
|
514
|
+
const f3 = (labels.length - 1) % 3 === 0;
|
|
515
|
+
// const f5 = (labels.length - 1) % 5 === 0;
|
|
516
|
+
|
|
517
|
+
while (repeat) {
|
|
518
|
+
repeat = false;
|
|
519
|
+
let extent = 0;
|
|
520
|
+
for (let i = 0; i < count; i += increment) {
|
|
521
|
+
const center = Math.round(area.left + initial_offset + step * i);
|
|
522
|
+
const left = center - metrics[i].width / 2;
|
|
523
|
+
if (extent && (left <= extent)) {
|
|
524
|
+
increment++;
|
|
525
|
+
repeat = true;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// FIXME: buffer? they get pretty tight sometimes
|
|
530
|
+
|
|
531
|
+
extent = center + (metrics[i].width / 2) + label_buffer;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// special patch for 0% - 100% range...
|
|
536
|
+
|
|
537
|
+
if (increment === 3 && !f3 && f2) {
|
|
538
|
+
increment++;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// const axis = this.GetAxisNode();
|
|
542
|
+
|
|
543
|
+
for (let i = 0; i < count; i += increment) {
|
|
544
|
+
const x = Math.round(area.left + initial_offset + step * i);
|
|
545
|
+
// if (x + metrics[i].width / 2 >= area.right) { break; }
|
|
546
|
+
this.RenderText(this.axis_group, labels[i], 'center', { x, y: area.bottom }, classes);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** specialization for bar; it's different enough that we want special treatment */
|
|
552
|
+
public RenderYAxisBar(area: Area, left: number,
|
|
553
|
+
labels: Array<{
|
|
554
|
+
label: string;
|
|
555
|
+
metrics: Metrics;
|
|
556
|
+
}>, classes?: string | string[]) {
|
|
557
|
+
|
|
558
|
+
labels = labels.slice(0);
|
|
559
|
+
labels.reverse();
|
|
560
|
+
|
|
561
|
+
const count = labels.length;
|
|
562
|
+
if (!count) return;
|
|
563
|
+
|
|
564
|
+
const step = area.height / count;
|
|
565
|
+
|
|
566
|
+
// calculate increment (skip count)
|
|
567
|
+
let increment = 1;
|
|
568
|
+
let repeat = true;
|
|
569
|
+
|
|
570
|
+
while (repeat) {
|
|
571
|
+
repeat = false;
|
|
572
|
+
let extent = 0;
|
|
573
|
+
for (let i = 0; i < count; i += increment) {
|
|
574
|
+
const label = labels[i];
|
|
575
|
+
const y = Math.round(area.bottom - step * (i + .5) + label.metrics.height / 4);
|
|
576
|
+
if (extent && y >= extent) {
|
|
577
|
+
increment++;
|
|
578
|
+
repeat = true;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
extent = y - label.metrics.height;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// const axis = this.GetAxisNode();
|
|
586
|
+
|
|
587
|
+
for (let i = 0; i < count; i += increment) {
|
|
588
|
+
const label = labels[i];
|
|
589
|
+
const y = Math.round(area.bottom - step * (i + .5) + label.metrics.height / 4);
|
|
590
|
+
this.RenderText(this.axis_group, label.label, 'right', { x: left, y }, classes);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* render y axis labels; skips over labels to prevent overlap
|
|
597
|
+
*/
|
|
598
|
+
public RenderYAxis(area: Area, left: number,
|
|
599
|
+
labels: Array<{
|
|
600
|
+
label: string;
|
|
601
|
+
metrics: Metrics;
|
|
602
|
+
}>, classes?: string | string[]) {
|
|
603
|
+
|
|
604
|
+
const count = labels.length;
|
|
605
|
+
if (!count) return;
|
|
606
|
+
|
|
607
|
+
const step = area.height / (count - 1);
|
|
608
|
+
|
|
609
|
+
// calculate increment (skip count)
|
|
610
|
+
let increment = 1;
|
|
611
|
+
let repeat = true;
|
|
612
|
+
|
|
613
|
+
while (repeat) {
|
|
614
|
+
repeat = false;
|
|
615
|
+
let extent = 0;
|
|
616
|
+
for (let i = 0; i < count; i += increment) {
|
|
617
|
+
const label = labels[i];
|
|
618
|
+
const y = Math.round(area.bottom - step * i + label.metrics.height / 4);
|
|
619
|
+
if (extent && y >= extent) {
|
|
620
|
+
increment++;
|
|
621
|
+
repeat = true;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
extent = y - label.metrics.height;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// const axis = this.GetAxisNode();
|
|
629
|
+
|
|
630
|
+
for (let i = 0; i < count; i += increment) {
|
|
631
|
+
const label = labels[i];
|
|
632
|
+
const y = Math.round(area.bottom - step * i + label.metrics.height / 4);
|
|
633
|
+
this.RenderText(this.axis_group, label.label, 'right', { x: left, y }, classes);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/*
|
|
639
|
+
public ControlPoint(current: Point, previous?: Point, next?: Point, reverse = false): Point {
|
|
640
|
+
|
|
641
|
+
previous = previous || current;
|
|
642
|
+
next = next || current;
|
|
643
|
+
|
|
644
|
+
const o = this.LineProperties(previous, next);
|
|
645
|
+
const factor = Math.pow(1 - Math.abs(o.angle) / Math.PI, 2) * this.smoothing_factor;
|
|
646
|
+
|
|
647
|
+
const angle = o.angle + (reverse ? Math.PI : 0);
|
|
648
|
+
const length = o.length * factor;
|
|
649
|
+
|
|
650
|
+
const x = current.x + Math.cos(angle) * length;
|
|
651
|
+
const y = current.y + Math.sin(angle) * length;
|
|
652
|
+
|
|
653
|
+
return { x, y };
|
|
654
|
+
|
|
655
|
+
}
|
|
656
|
+
*/
|
|
657
|
+
|
|
658
|
+
public LineProperties(a: Point, b: Point) {
|
|
659
|
+
|
|
660
|
+
const x = b.x - a.x;
|
|
661
|
+
const y = b.y - a.y;
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
length: Math.sqrt((x * x) + (y * y)),
|
|
665
|
+
angle: Math.atan2(y, x),
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
public RenderSmoothLine(
|
|
671
|
+
area: Area,
|
|
672
|
+
data: Array<number | undefined>,
|
|
673
|
+
fill = false,
|
|
674
|
+
titles?: string[],
|
|
675
|
+
classes?: string | string[]): void {
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
// const node = document.createElementNS(SVGNS, 'path');
|
|
679
|
+
const group = SVGNode('g');
|
|
680
|
+
|
|
681
|
+
const d1: string[] = [];
|
|
682
|
+
const d2: string[] = [];
|
|
683
|
+
|
|
684
|
+
const count = data.length;
|
|
685
|
+
const steps = count - 1;
|
|
686
|
+
const step = (area.width / count) / 2;
|
|
687
|
+
|
|
688
|
+
const circles: Array<{
|
|
689
|
+
x: number;
|
|
690
|
+
y: number;
|
|
691
|
+
i: number;
|
|
692
|
+
}> = [];
|
|
693
|
+
|
|
694
|
+
const points: Array<Point | undefined> = data.map((value, i) => {
|
|
695
|
+
if (typeof value === 'undefined') {
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
x: Math.round(area.left + area.width / steps * i),
|
|
700
|
+
y: area.bottom - value,
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
///
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
// we need to split into segments in the event of missing data
|
|
708
|
+
|
|
709
|
+
let segment: Point[] = [];
|
|
710
|
+
const render_segment = () => {
|
|
711
|
+
|
|
712
|
+
if (segment.length < 2){ return; }
|
|
713
|
+
|
|
714
|
+
let line = '';
|
|
715
|
+
const first = segment[0];
|
|
716
|
+
const last = segment[segment.length-1];
|
|
717
|
+
|
|
718
|
+
// note here we're not adding the leading M because for area,
|
|
719
|
+
// we want to use an L instead (or it won't be contiguous)
|
|
720
|
+
|
|
721
|
+
if (segment.length === 2) {
|
|
722
|
+
line = `${segment[0].x},${segment[0].y} L${segment[1].x},${segment[1].y}`;
|
|
723
|
+
}
|
|
724
|
+
else if (segment.length > 2) {
|
|
725
|
+
const curve = this.CatmullRomChain(segment);
|
|
726
|
+
line = '' + curve.map(point => `${point.x},${point.y}`).join(' L');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (line) {
|
|
730
|
+
d1.push('M' + line);
|
|
731
|
+
if (fill) {
|
|
732
|
+
d2.push(`M ${first.x},${area.bottom} L ${first.x},${first.y}`);
|
|
733
|
+
d2.push('L' + line);
|
|
734
|
+
d2.push(`L ${last.x},${area.bottom}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
for (const point of points) {
|
|
741
|
+
if (!point) {
|
|
742
|
+
render_segment();
|
|
743
|
+
segment = [];
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
segment.push(point);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// render?
|
|
750
|
+
if (segment.length) {
|
|
751
|
+
render_segment();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
///
|
|
756
|
+
|
|
757
|
+
/*
|
|
758
|
+
|
|
759
|
+
for (let i = 0; i < points.length; i++) {
|
|
760
|
+
|
|
761
|
+
const point = points[i];
|
|
762
|
+
|
|
763
|
+
if (point) {
|
|
764
|
+
if (move) {
|
|
765
|
+
d1.push(`M ${[point.x]},${point.y}`);
|
|
766
|
+
if (fill) {
|
|
767
|
+
d2.push(`M ${point.x} ${area.bottom} L ${[point.x]},${point.y}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
const cp_start = this.ControlPoint(points[i - 1] as Point, points[i - 2], point);
|
|
772
|
+
const cp_end = this.ControlPoint(point, points[i - 1], points[i + 1], true);
|
|
773
|
+
d1.push(`C ${cp_start.x},${cp_start.y} ${cp_end.x},${cp_end.y} ${point.x},${point.y}`);
|
|
774
|
+
d2.push(`C ${cp_start.x},${cp_start.y} ${cp_end.x},${cp_end.y} ${point.x},${point.y}`);
|
|
775
|
+
}
|
|
776
|
+
move = false;
|
|
777
|
+
last_point = point;
|
|
778
|
+
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
move = true;
|
|
782
|
+
if (fill && last_point) {
|
|
783
|
+
d2.push(`L ${last_point.x},${area.bottom} Z`);
|
|
784
|
+
}
|
|
785
|
+
last_point = undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (fill && last_point) {
|
|
791
|
+
d2.push(`L ${last_point.x},${area.bottom} Z`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
*/
|
|
795
|
+
|
|
796
|
+
/*
|
|
797
|
+
|
|
798
|
+
for (; i < count; i++ ){
|
|
799
|
+
const point = data[i];
|
|
800
|
+
if (typeof point === 'undefined') {
|
|
801
|
+
move = true;
|
|
802
|
+
if (fill && (typeof last_x !== 'undefined')) {
|
|
803
|
+
d2.push(`L${last_x} ${area.bottom}Z`);
|
|
804
|
+
}
|
|
805
|
+
last_x = undefined;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const x = Math.round(area.left + area.width / steps * i);
|
|
809
|
+
if (move) {
|
|
810
|
+
if (fill) {
|
|
811
|
+
d2.push(`M${x} ${area.bottom} L${x} ${area.bottom - point}`);
|
|
812
|
+
}
|
|
813
|
+
d1.push(`M${x} ${area.bottom - point}`);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
d1.push(`L${x} ${area.bottom - point}`);
|
|
817
|
+
d2.push(`L${x} ${area.bottom - point}`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
circles.push({x, y: area.bottom - point, i});
|
|
821
|
+
|
|
822
|
+
last_x = x;
|
|
823
|
+
move = false;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
*/
|
|
827
|
+
|
|
828
|
+
/*
|
|
829
|
+
if (fill && (typeof last_x !== 'undefined')) {
|
|
830
|
+
d2.push(`L${last_x} ${area.bottom}Z`);
|
|
831
|
+
}
|
|
832
|
+
*/
|
|
833
|
+
|
|
834
|
+
// fill first, underneath
|
|
835
|
+
if (fill) {
|
|
836
|
+
group.appendChild(SVGNode('path', { class: 'fill', d: d2 }));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// then line
|
|
840
|
+
group.appendChild(SVGNode('path', { class: 'line', d: d1 }));
|
|
841
|
+
|
|
842
|
+
if (typeof classes !== 'undefined') {
|
|
843
|
+
if (typeof classes === 'string') {
|
|
844
|
+
classes = [classes];
|
|
845
|
+
}
|
|
846
|
+
group.setAttribute('class', classes.join(' '));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
this.group.appendChild(group);
|
|
850
|
+
|
|
851
|
+
// circles...
|
|
852
|
+
|
|
853
|
+
if (titles && circles.length) {
|
|
854
|
+
const circle_group = document.createElementNS(SVGNS, 'g');
|
|
855
|
+
for (const circle of circles) {
|
|
856
|
+
|
|
857
|
+
const shape = SVGNode('circle', {cx: circle.x, cy: circle.y, r: step});
|
|
858
|
+
|
|
859
|
+
shape.addEventListener('mouseenter', (event) => {
|
|
860
|
+
this.parent.setAttribute('title', titles[circle.i] || '');
|
|
861
|
+
});
|
|
862
|
+
shape.addEventListener('mouseleave', (event) => {
|
|
863
|
+
this.parent.setAttribute('title', '');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
circle_group.appendChild(shape);
|
|
867
|
+
}
|
|
868
|
+
circle_group.setAttribute('class', 'mouse-layer');
|
|
869
|
+
this.group.appendChild(circle_group);
|
|
870
|
+
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
public RenderLine(
|
|
875
|
+
area: Area,
|
|
876
|
+
data: Array<number | undefined>,
|
|
877
|
+
fill = false,
|
|
878
|
+
titles?: string[],
|
|
879
|
+
classes?: string | string[]) {
|
|
880
|
+
|
|
881
|
+
// const node = document.createElementNS(SVGNS, 'path');
|
|
882
|
+
const group = document.createElementNS(SVGNS, 'g');
|
|
883
|
+
|
|
884
|
+
const d1: string[] = [];
|
|
885
|
+
const d2: string[] = [];
|
|
886
|
+
|
|
887
|
+
const count = data.length;
|
|
888
|
+
const steps = count - 1;
|
|
889
|
+
const step = (area.width / count) / 2;
|
|
890
|
+
|
|
891
|
+
const circles: Array<{
|
|
892
|
+
x: number;
|
|
893
|
+
y: number;
|
|
894
|
+
i: number;
|
|
895
|
+
}> = [];
|
|
896
|
+
|
|
897
|
+
let i = 0;
|
|
898
|
+
let move = true;
|
|
899
|
+
let last_x: number | undefined;
|
|
900
|
+
|
|
901
|
+
for (; i < count; i++) {
|
|
902
|
+
const point = data[i];
|
|
903
|
+
if (typeof point === 'undefined') {
|
|
904
|
+
move = true;
|
|
905
|
+
if (fill && (typeof last_x !== 'undefined')) {
|
|
906
|
+
d2.push(`L${last_x} ${area.bottom}Z`);
|
|
907
|
+
}
|
|
908
|
+
last_x = undefined;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const x = Math.round(/*step*/ + area.left + area.width / steps * i);
|
|
912
|
+
if (move) {
|
|
913
|
+
if (fill) {
|
|
914
|
+
d2.push(`M${x} ${area.bottom} L${x} ${area.bottom - point}`);
|
|
915
|
+
}
|
|
916
|
+
d1.push(`M${x} ${area.bottom - point}`);
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
d1.push(`L${x} ${area.bottom - point}`);
|
|
920
|
+
d2.push(`L${x} ${area.bottom - point}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
circles.push({ x, y: area.bottom - point, i });
|
|
924
|
+
|
|
925
|
+
last_x = x;
|
|
926
|
+
move = false;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (fill && (typeof last_x !== 'undefined')) {
|
|
930
|
+
d2.push(`L${last_x} ${area.bottom}Z`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// fill first, underneath
|
|
934
|
+
if (fill) {
|
|
935
|
+
group.appendChild(SVGNode('path', { class: 'fill', d: d2 }));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// then line
|
|
939
|
+
group.appendChild(SVGNode('path', { class: 'line', d: d1 }));
|
|
940
|
+
|
|
941
|
+
if (typeof classes !== 'undefined') {
|
|
942
|
+
if (typeof classes === 'string') {
|
|
943
|
+
classes = [classes];
|
|
944
|
+
}
|
|
945
|
+
group.setAttribute('class', classes.join(' '));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
this.group.appendChild(group);
|
|
949
|
+
|
|
950
|
+
// circles...
|
|
951
|
+
|
|
952
|
+
if (titles && circles.length) {
|
|
953
|
+
const circle_group = document.createElementNS(SVGNS, 'g');
|
|
954
|
+
for (const circle of circles) {
|
|
955
|
+
|
|
956
|
+
const shape = SVGNode('circle', { cx: circle.x, cy: circle.y, r: step });
|
|
957
|
+
|
|
958
|
+
/*
|
|
959
|
+
const shape = document.createElementNS(SVGNS, 'circle');
|
|
960
|
+
shape.setAttribute('cx', circle.x.toString());
|
|
961
|
+
shape.setAttribute('cy', circle.y.toString());
|
|
962
|
+
shape.setAttribute('r', (step).toString());
|
|
963
|
+
*/
|
|
964
|
+
|
|
965
|
+
shape.addEventListener('mouseenter', (event) => {
|
|
966
|
+
this.parent.setAttribute('title', titles[circle.i] || '');
|
|
967
|
+
});
|
|
968
|
+
shape.addEventListener('mouseleave', (event) => {
|
|
969
|
+
this.parent.setAttribute('title', '');
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
circle_group.appendChild(shape);
|
|
973
|
+
}
|
|
974
|
+
circle_group.setAttribute('class', 'mouse-layer');
|
|
975
|
+
this.group.appendChild(circle_group);
|
|
976
|
+
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* the other RenderGrid function has semantics specifically for area/line.
|
|
983
|
+
* rather than try to shoehorn this in we'll use a different method.
|
|
984
|
+
*/
|
|
985
|
+
public RenderBarGrid(area: Area, x_count: number, classes?: string | string[]): void {
|
|
986
|
+
|
|
987
|
+
const d: string[] = [];
|
|
988
|
+
|
|
989
|
+
const step = area.width / (x_count);
|
|
990
|
+
for (let i = 0; i <= x_count; i++) {
|
|
991
|
+
const x = Math.round(area.left + step * i) - 0.5;
|
|
992
|
+
d.push(`M${x} ${area.top} L${x} ${area.bottom}`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
this.group.appendChild(SVGNode('path', { d, class: classes }));
|
|
996
|
+
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
public RenderGrid(area: Area, y_count: number, x_count = 0, classes?: string | string[]): void {
|
|
1000
|
+
|
|
1001
|
+
const d: string[] = [];
|
|
1002
|
+
|
|
1003
|
+
let step = area.height / y_count;
|
|
1004
|
+
for (let i = 0; i <= y_count; i++) {
|
|
1005
|
+
const y = Math.round(area.top + step * i) - 0.5;
|
|
1006
|
+
d.push(`M${area.left} ${y} L${area.right} ${y}`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
step = area.width / (x_count - 1);
|
|
1010
|
+
for (let i = 0; i < x_count; i++) {
|
|
1011
|
+
const x = Math.round(area.left + step * i) - 0.5;
|
|
1012
|
+
d.push(`M${x} ${area.top} L${x} ${area.bottom}`);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
this.group.appendChild(SVGNode('path', {d, class: classes}));
|
|
1016
|
+
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/* *
|
|
1020
|
+
* return the intersection point of two lines (assuming
|
|
1021
|
+
* infinite projection) or undefined if they are parallel
|
|
1022
|
+
* /
|
|
1023
|
+
public LineIntersection(a1: Point, a2: Point, b1: Point, b2: Point): Point|undefined {
|
|
1024
|
+
|
|
1025
|
+
const det = ((a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x));
|
|
1026
|
+
|
|
1027
|
+
if (!det) {
|
|
1028
|
+
return undefined; // parallel
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const t = ((a1.x - b1.x) * (b1.y - b2.y) - (a1.y - b1.y) * (b1.x - b2.x)) / det;
|
|
1032
|
+
|
|
1033
|
+
return { x: a1.x + t * (a2.x - a1.x), y: a1.y + t * (a2.y - a1.y) };
|
|
1034
|
+
|
|
1035
|
+
}
|
|
1036
|
+
*/
|
|
1037
|
+
|
|
1038
|
+
public MultiplyPoint(point: Point, scalar: number): Point {
|
|
1039
|
+
return {
|
|
1040
|
+
x: point.x * scalar,
|
|
1041
|
+
y: point.y * scalar,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
public AddPoints(a: Point, b: Point): Point {
|
|
1046
|
+
return {
|
|
1047
|
+
x: a.x + b.x,
|
|
1048
|
+
y: a.y + b.y,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* algo from
|
|
1054
|
+
* https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline
|
|
1055
|
+
*/
|
|
1056
|
+
|
|
1057
|
+
public CatmullRomSpline(P: Point[], n: number): Point[] {
|
|
1058
|
+
|
|
1059
|
+
// Parametric constant: 0.5 for the centripetal spline,
|
|
1060
|
+
// 0.0 for the uniform spline, 1.0 for the chordal spline.
|
|
1061
|
+
let alpha = .5;
|
|
1062
|
+
|
|
1063
|
+
// Premultiplied power constant for the following tj() function.
|
|
1064
|
+
alpha = alpha/2;
|
|
1065
|
+
const tj = (ti: number, Pi: Point, Pj: Point) => {
|
|
1066
|
+
const {x: xi, y: yi} = Pi
|
|
1067
|
+
const {x: xj, y: yj} = Pj
|
|
1068
|
+
return ((xj-xi)**2 + (yj-yi)**2)**alpha + ti;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const t0 = 0
|
|
1072
|
+
const t1 = tj(t0, P[0], P[1]);
|
|
1073
|
+
const t2 = tj(t1, P[1], P[2]);
|
|
1074
|
+
const t3 = tj(t2, P[2], P[3]);
|
|
1075
|
+
|
|
1076
|
+
const step = (t2-t1) / n;
|
|
1077
|
+
|
|
1078
|
+
const points: Point[] = [];
|
|
1079
|
+
|
|
1080
|
+
for (let i = 0; i < n; i++){
|
|
1081
|
+
const t = t1 + step * i;
|
|
1082
|
+
|
|
1083
|
+
const A1 = this.AddPoints(
|
|
1084
|
+
this.MultiplyPoint(P[0], (t1-t)/(t1-t0)),
|
|
1085
|
+
this.MultiplyPoint(P[1], (t-t0)/(t1-t0)),
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
const A2 = this.AddPoints(
|
|
1089
|
+
this.MultiplyPoint(P[1], (t2-t)/(t2-t1)),
|
|
1090
|
+
this.MultiplyPoint(P[2], (t-t1)/(t2-t1)),
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
const A3 = this.AddPoints(
|
|
1094
|
+
this.MultiplyPoint(P[2], (t3-t)/(t3-t2)),
|
|
1095
|
+
this.MultiplyPoint(P[3], (t-t2)/(t3-t2)),
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
const B1 = this.AddPoints(
|
|
1099
|
+
this.MultiplyPoint(A1, (t2-t)/(t2-t0)),
|
|
1100
|
+
this.MultiplyPoint(A2, (t-t0)/(t2-t0)),
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
const B2 = this.AddPoints(
|
|
1104
|
+
this.MultiplyPoint(A2, (t3-t)/(t3-t1)),
|
|
1105
|
+
this.MultiplyPoint(A3, (t-t1)/(t3-t1)),
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const C = this.AddPoints(
|
|
1109
|
+
this.MultiplyPoint(B1, (t2-t)/(t2-t1)),
|
|
1110
|
+
this.MultiplyPoint(B2, (t-t1)/(t2-t1)),
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
points.push(C);
|
|
1114
|
+
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return points;
|
|
1118
|
+
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* NOTE: we are munging the point list here, so don't use it after
|
|
1123
|
+
* calling this function or pass in a temp copy
|
|
1124
|
+
*
|
|
1125
|
+
* OK so that was rude, we will not munge the list
|
|
1126
|
+
*/
|
|
1127
|
+
public CatmullRomChain(original: Point[], n = 30): Point[] {
|
|
1128
|
+
|
|
1129
|
+
const points = original.slice(0);
|
|
1130
|
+
|
|
1131
|
+
const result: Point[] = [];
|
|
1132
|
+
const len = points.length;
|
|
1133
|
+
|
|
1134
|
+
if (len) {
|
|
1135
|
+
|
|
1136
|
+
// add two trailing points, extended linearly from existing segmnet
|
|
1137
|
+
|
|
1138
|
+
let dx = points[len-1].x - points[len-2].x;
|
|
1139
|
+
let dy = points[len-1].y - points[len-2].y;
|
|
1140
|
+
|
|
1141
|
+
points.push({
|
|
1142
|
+
x: points[len-1].x + dx,
|
|
1143
|
+
y: points[len-1].y + dy,
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
points.push({
|
|
1147
|
+
x: points[len-1].x + dx,
|
|
1148
|
+
y: points[len-1].y + dy,
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// some for the first point, in the other direction
|
|
1152
|
+
|
|
1153
|
+
dx = points[1].x - points[0].x;
|
|
1154
|
+
dy = points[1].y - points[0].y;
|
|
1155
|
+
|
|
1156
|
+
points.unshift({
|
|
1157
|
+
x: points[0].x - dx,
|
|
1158
|
+
y: points[0].y - dy,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
for (let i = 0; i < points.length - 4; i++) {
|
|
1162
|
+
const subset = points.slice(i, i + 4);
|
|
1163
|
+
const step = this.CatmullRomSpline(subset, n);
|
|
1164
|
+
result.push(...step);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return result;
|
|
1170
|
+
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
public RenderDataLabels(
|
|
1174
|
+
area: Area,
|
|
1175
|
+
x: Array<number | undefined>,
|
|
1176
|
+
y: Array<number | undefined>,
|
|
1177
|
+
x_scale: RangeScale,
|
|
1178
|
+
y_scale: RangeScale,
|
|
1179
|
+
data_labels: Array<string|undefined>,
|
|
1180
|
+
series_index: number ): void {
|
|
1181
|
+
|
|
1182
|
+
// const label_group = SVGNode('g');
|
|
1183
|
+
// this.group.appendChild(label_group);
|
|
1184
|
+
|
|
1185
|
+
const count = Math.max(x.length, y.length);
|
|
1186
|
+
const xrange = (x_scale.max - x_scale.min) || 1;
|
|
1187
|
+
const yrange = (y_scale.max - y_scale.min) || 1;
|
|
1188
|
+
|
|
1189
|
+
for (let i = 0; i < count; i++) {
|
|
1190
|
+
|
|
1191
|
+
const a = x[i];
|
|
1192
|
+
const b = y[i];
|
|
1193
|
+
|
|
1194
|
+
if (a !== undefined && b !== undefined) {
|
|
1195
|
+
const point ={
|
|
1196
|
+
x: area.left + ((a - x_scale.min) / xrange) * area.width,
|
|
1197
|
+
y: area.bottom - ((b - y_scale.min) / yrange) * area.height,
|
|
1198
|
+
};
|
|
1199
|
+
const label = data_labels[i];
|
|
1200
|
+
if (label) {
|
|
1201
|
+
|
|
1202
|
+
this.label_group.appendChild(SVGNode('circle', {class: 'label-target', cx: point.x, cy: point.y, r: 10 }));
|
|
1203
|
+
|
|
1204
|
+
const g = SVGNode('g', {class: 'data-label', transform: `translate(${point.x + 10},${point.y})`});
|
|
1205
|
+
this.label_group.appendChild(g);
|
|
1206
|
+
|
|
1207
|
+
const circle = SVGNode('circle', {
|
|
1208
|
+
cx: -10, y: 0, r: 5, class: `marker-highlight series-${series_index}`
|
|
1209
|
+
});
|
|
1210
|
+
g.appendChild(circle);
|
|
1211
|
+
|
|
1212
|
+
const text = SVGNode('text', {x: 4, y: 0}, label);
|
|
1213
|
+
g.appendChild(text);
|
|
1214
|
+
const bounds = text.getBoundingClientRect();
|
|
1215
|
+
const h = bounds.height;
|
|
1216
|
+
const w = bounds.width + 8;
|
|
1217
|
+
|
|
1218
|
+
if (w + 15 + point.x >= area.right) {
|
|
1219
|
+
g.setAttribute('transform', `translate(${point.x - w - 15},${point.y})`)
|
|
1220
|
+
circle.setAttribute('cx', (w + 15).toString());
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const rect = SVGNode('path', {d:`M0,5 h${w} v-${h} h-${w} Z`});
|
|
1224
|
+
g.insertBefore(rect, text);
|
|
1225
|
+
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
public RenderScatterSeries(area: Area,
|
|
1233
|
+
x: Array<number | undefined>,
|
|
1234
|
+
y: Array<number | undefined>,
|
|
1235
|
+
x_scale: RangeScale,
|
|
1236
|
+
y_scale: RangeScale,
|
|
1237
|
+
lines = true,
|
|
1238
|
+
plot_points = false,
|
|
1239
|
+
filled = false,
|
|
1240
|
+
markers = false,
|
|
1241
|
+
smooth = false,
|
|
1242
|
+
classes?: string | string[]): void {
|
|
1243
|
+
|
|
1244
|
+
// ...
|
|
1245
|
+
|
|
1246
|
+
const count = Math.max(x.length, y.length);
|
|
1247
|
+
const xrange = (x_scale.max - x_scale.min) || 1;
|
|
1248
|
+
const yrange = (y_scale.max - y_scale.min) || 1;
|
|
1249
|
+
|
|
1250
|
+
// const marker_elements: string[] = [];
|
|
1251
|
+
const points: Array<Point | undefined> = [];
|
|
1252
|
+
|
|
1253
|
+
const d: string[] = [];
|
|
1254
|
+
const areas: string[] = [];
|
|
1255
|
+
|
|
1256
|
+
/*
|
|
1257
|
+
const group = document.createElementNS(SVGNS, 'g');
|
|
1258
|
+
if (typeof classes !== 'undefined') {
|
|
1259
|
+
if (typeof classes === 'string') {
|
|
1260
|
+
classes = [classes];
|
|
1261
|
+
}
|
|
1262
|
+
group.setAttribute('class', classes.join(' '));
|
|
1263
|
+
}
|
|
1264
|
+
*/
|
|
1265
|
+
const group = SVGNode('g', {class: classes});
|
|
1266
|
+
|
|
1267
|
+
// if (title) node.setAttribute('title', title);
|
|
1268
|
+
this.group.appendChild(group);
|
|
1269
|
+
|
|
1270
|
+
for (let i = 0; i < count; i++) {
|
|
1271
|
+
|
|
1272
|
+
const a = x[i];
|
|
1273
|
+
const b = y[i];
|
|
1274
|
+
|
|
1275
|
+
if (typeof a === 'undefined' || typeof b === 'undefined') {
|
|
1276
|
+
points.push(undefined);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
points.push({
|
|
1280
|
+
x: area.left + ((a - x_scale.min) / xrange) * area.width,
|
|
1281
|
+
y: area.bottom - ((b - y_scale.min) / yrange) * area.height,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// FIXME: merge loops, if possible
|
|
1288
|
+
|
|
1289
|
+
/*
|
|
1290
|
+
if (markers) {
|
|
1291
|
+
for (const point of points) {
|
|
1292
|
+
if (point) {
|
|
1293
|
+
|
|
1294
|
+
// if we can't use CSS to update the path (except in chrome)
|
|
1295
|
+
// then it's probably not worth it... leave it for now
|
|
1296
|
+
|
|
1297
|
+
// marker_elements.push(`<path d='M0,-1.5 a1.5,1.5,0,1,1,0,3 a1.5,1.5,0,1,1,0,-3' transform='translate(${point.x},${point.y})' class='marker'/>`);
|
|
1298
|
+
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
*/
|
|
1303
|
+
|
|
1304
|
+
if (lines) {
|
|
1305
|
+
|
|
1306
|
+
// we need to split into segments in the event of missing data
|
|
1307
|
+
|
|
1308
|
+
let segment: Point[] = [];
|
|
1309
|
+
const render_segment = smooth ? () => {
|
|
1310
|
+
|
|
1311
|
+
// segments < 3 should be straight lines (or points)
|
|
1312
|
+
if (segment.length === 2) {
|
|
1313
|
+
return `${segment[0].x},${segment[0].y} L${segment[1].x},${segment[1].y}`;
|
|
1314
|
+
}
|
|
1315
|
+
else if (segment.length > 2) {
|
|
1316
|
+
const curve = this.CatmullRomChain(segment);
|
|
1317
|
+
return curve.map(point => `${point.x},${point.y}`).join(' L');
|
|
1318
|
+
}
|
|
1319
|
+
return '';
|
|
1320
|
+
|
|
1321
|
+
} : () => {
|
|
1322
|
+
return segment.map(point => `${point.x},${point.y}`).join(' L');
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
for (const point of points) {
|
|
1326
|
+
if (!point) {
|
|
1327
|
+
if (segment.length >= 2) {
|
|
1328
|
+
const line = render_segment();
|
|
1329
|
+
d.push('M' + line);
|
|
1330
|
+
areas.push(`M${segment[0].x},${area.bottom}L` + line + `L${segment[segment.length - 1].x},${area.bottom}Z`);
|
|
1331
|
+
}
|
|
1332
|
+
segment = [];
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
segment.push(point);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (segment.length >= 2) {
|
|
1340
|
+
const line = render_segment();
|
|
1341
|
+
d.push('M' + line);
|
|
1342
|
+
areas.push(`M${segment[0].x},${area.bottom}L` + line + `L${segment[segment.length - 1].x},${area.bottom}Z`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (filled) {
|
|
1349
|
+
group.appendChild(SVGNode('path', {d: areas, class: 'fill'}));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (lines) {
|
|
1353
|
+
group.appendChild(SVGNode('path', {d, class: 'line'}));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (plot_points) {
|
|
1357
|
+
for (const point of points) {
|
|
1358
|
+
if (point) {
|
|
1359
|
+
group.appendChild(SVGNode('circle', {cx: point.x, cy: point.y, r: 1, class: 'point'}));
|
|
1360
|
+
|
|
1361
|
+
// if we can't use CSS to update the path (except in chrome)
|
|
1362
|
+
// then it's probably not worth it... leave it for now
|
|
1363
|
+
|
|
1364
|
+
// marker_elements.push(`<path d='M0,-1.5 a1.5,1.5,0,1,1,0,3 a1.5,1.5,0,1,1,0,-3' transform='translate(${point.x},${point.y})' class='marker'/>`);
|
|
1365
|
+
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (markers) {
|
|
1372
|
+
for (const point of points) {
|
|
1373
|
+
if (point) {
|
|
1374
|
+
group.appendChild(SVGNode('circle', {cx: point.x, cy: point.y, r: 3, class: 'marker'}));
|
|
1375
|
+
|
|
1376
|
+
// if we can't use CSS to update the path (except in chrome)
|
|
1377
|
+
// then it's probably not worth it... leave it for now
|
|
1378
|
+
|
|
1379
|
+
// marker_elements.push(`<path d='M0,-1.5 a1.5,1.5,0,1,1,0,3 a1.5,1.5,0,1,1,0,-3' transform='translate(${point.x},${point.y})' class='marker'/>`);
|
|
1380
|
+
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
// SetSVG(group, `<path d='${d.join(' ')}' class='line' />${marker_elements.join('')}`);
|
|
1387
|
+
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
public RenderPoints(area: Area, x: number[], y: number[], classes?: string | string[]) {
|
|
1392
|
+
|
|
1393
|
+
// const node = document.createElementNS(SVGNS, 'path');
|
|
1394
|
+
const d: string[] = [];
|
|
1395
|
+
|
|
1396
|
+
for (let i = 0; i < x.length; i++) {
|
|
1397
|
+
const px = x[i] * area.width + area.left;
|
|
1398
|
+
const py = area.bottom - y[i] * area.height;
|
|
1399
|
+
d.push(`M${px - 1},${py - 1} L${px + 1},${py + 1}`);
|
|
1400
|
+
d.push(`M${px - 1},${py + 1} L${px + 1},${py - 1}`);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
this.group.appendChild(SVGNode('path', {d, class: classes}));
|
|
1404
|
+
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
public RenderPoint(cx: number, cy: number, classes?: string | string[]): void {
|
|
1408
|
+
this.group.appendChild(SVGNode('circle', {cx, cy, r: 1, class: classes}));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
public RenderCalloutLines(lines: Array<{x1: number, y1: number, x2: number, y2:number, label?: string, classes?: string }>): void {
|
|
1412
|
+
|
|
1413
|
+
const g = SVGNode('g', {class: 'callouts'});
|
|
1414
|
+
this.label_group.appendChild(g);
|
|
1415
|
+
|
|
1416
|
+
for (const line of lines) {
|
|
1417
|
+
g.appendChild(SVGNode('path', {
|
|
1418
|
+
d: `M${line.x1},${line.y1} L${line.x2},${line.y2}`,
|
|
1419
|
+
class: 'callout ' + (line.classes || '').trim(),
|
|
1420
|
+
}));
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
public RenderRectangle(
|
|
1426
|
+
area: Area,
|
|
1427
|
+
corner_radius?: number[],
|
|
1428
|
+
classes?: string | string[],
|
|
1429
|
+
title?: string,
|
|
1430
|
+
label?: string,
|
|
1431
|
+
label_point?: Point): void {
|
|
1432
|
+
|
|
1433
|
+
let d = '';
|
|
1434
|
+
|
|
1435
|
+
if (corner_radius) {
|
|
1436
|
+
|
|
1437
|
+
// two cases we have to worry about: top L/R corner radius > height,
|
|
1438
|
+
// and top/bottom L radius > width
|
|
1439
|
+
|
|
1440
|
+
if (corner_radius[0] &&
|
|
1441
|
+
corner_radius[0] === corner_radius[1] &&
|
|
1442
|
+
corner_radius[0] >= area.height) {
|
|
1443
|
+
|
|
1444
|
+
const c = corner_radius[0];
|
|
1445
|
+
const b = corner_radius[0] - area.height;
|
|
1446
|
+
const a = Math.sqrt(c * c - b * b);
|
|
1447
|
+
|
|
1448
|
+
d = `M${area.left + area.width / 2 - a},${area.bottom} a${c},${c} 0 0 1 ${a * 2},0 z`;
|
|
1449
|
+
|
|
1450
|
+
}
|
|
1451
|
+
else if (corner_radius[1] &&
|
|
1452
|
+
corner_radius[1] === corner_radius[2] &&
|
|
1453
|
+
corner_radius[1] >= area.width) {
|
|
1454
|
+
|
|
1455
|
+
const c = corner_radius[1];
|
|
1456
|
+
const b = corner_radius[1] - area.width;
|
|
1457
|
+
const a = Math.sqrt(c * c - b * b);
|
|
1458
|
+
|
|
1459
|
+
d = `M${area.left},${area.top + area.height / 2 - a} a${c},${c} 0 0 1 0,${a * 2} z`;
|
|
1460
|
+
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
d = `M${area.left},${area.top + corner_radius[0]} `
|
|
1464
|
+
+ `a${corner_radius[0]},${corner_radius[0]} 0 0 1 ${corner_radius[0]},${-corner_radius[0]} `
|
|
1465
|
+
+ `h${area.width - corner_radius[0] - corner_radius[1]} `
|
|
1466
|
+
+ `a${corner_radius[1]},${corner_radius[1]} 0 0 1 ${corner_radius[1]},${corner_radius[1]} `
|
|
1467
|
+
+ `v${area.height - corner_radius[1] - corner_radius[2]} `
|
|
1468
|
+
+ `a${corner_radius[2]},${corner_radius[2]} 0 0 1 ${-corner_radius[2]},${corner_radius[2]} `
|
|
1469
|
+
+ `h${-area.width + corner_radius[2] + corner_radius[3]} `
|
|
1470
|
+
+ `a${corner_radius[3]},${corner_radius[3]} 0 0 1 ${-corner_radius[3]},${-corner_radius[3]} `
|
|
1471
|
+
+ `v${-area.height + corner_radius[3] + corner_radius[0]} `;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
/*
|
|
1476
|
+
node = SVGNode('rect', {
|
|
1477
|
+
x: area.left,
|
|
1478
|
+
y: area.top,
|
|
1479
|
+
width: area.width,
|
|
1480
|
+
height: area.height,
|
|
1481
|
+
class: classes });
|
|
1482
|
+
*/
|
|
1483
|
+
|
|
1484
|
+
d = `M${area.left},${area.top} `
|
|
1485
|
+
+ `h${area.width} `
|
|
1486
|
+
+ `v${area.height} `
|
|
1487
|
+
+ `h${-area.width} `
|
|
1488
|
+
+ `v${-area.height} `;
|
|
1489
|
+
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const node = SVGNode('path', {
|
|
1493
|
+
d, class: classes,
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
if (title) {
|
|
1497
|
+
node.addEventListener('mouseenter', (event) => {
|
|
1498
|
+
this.parent.setAttribute('title', title);
|
|
1499
|
+
});
|
|
1500
|
+
node.addEventListener('mouseleave', (event) => {
|
|
1501
|
+
this.parent.setAttribute('title', '');
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
this.group.appendChild(node);
|
|
1506
|
+
|
|
1507
|
+
if (label) {
|
|
1508
|
+
|
|
1509
|
+
this.label_group.appendChild(SVGNode('path', {class: 'label-target', d }));
|
|
1510
|
+
|
|
1511
|
+
const point = label_point || {
|
|
1512
|
+
x: Math.round(area.left + area.width / 2),
|
|
1513
|
+
y: Math.round(area.top - 10),
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
const g = SVGNode('g', {class: 'data-label', transform: `translate(${point.x},${point.y})`});
|
|
1517
|
+
this.label_group.appendChild(g);
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
const text = SVGNode('text', {x: 0, y: 0}, label);
|
|
1521
|
+
g.appendChild(text);
|
|
1522
|
+
const bounds = text.getBoundingClientRect();
|
|
1523
|
+
const h = bounds.height;
|
|
1524
|
+
const w = bounds.width + 8;
|
|
1525
|
+
|
|
1526
|
+
if (point.y - bounds.height < 4) {
|
|
1527
|
+
point.y -= (point.y - bounds.height - 4);
|
|
1528
|
+
g.setAttribute('transform', `translate(${point.x},${point.y})`);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
text.setAttribute('x', Math.floor(-bounds.width/2).toString());
|
|
1532
|
+
|
|
1533
|
+
/*
|
|
1534
|
+
if (w + 15 + point.x >= area.right) {
|
|
1535
|
+
g.setAttribute('transform', `translate(${point.x - w - 15},${point.y})`)
|
|
1536
|
+
// circle.setAttribute('cx', (w + 15).toString());
|
|
1537
|
+
}
|
|
1538
|
+
*/
|
|
1539
|
+
|
|
1540
|
+
const vertical_padding = Math.ceil(h * .125);
|
|
1541
|
+
|
|
1542
|
+
// const rect = SVGNode('path', {d:`M${-w/2},${vertical_padding} h${w} v-${h + vertical_padding / 2} h-${w} Z`});
|
|
1543
|
+
const rect = SVGNode('rect', {rx: 3, x: -w/2, y: Math.round(-h + vertical_padding * 2/3), width: w, height: h + vertical_padding});
|
|
1544
|
+
g.insertBefore(rect, text);
|
|
1545
|
+
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* render text at point
|
|
1552
|
+
*/
|
|
1553
|
+
public RenderText(
|
|
1554
|
+
target: SVGElement|undefined,
|
|
1555
|
+
text: string,
|
|
1556
|
+
align: 'center' | 'left' | 'right',
|
|
1557
|
+
point: Point,
|
|
1558
|
+
classes?: string | string[]): void {
|
|
1559
|
+
|
|
1560
|
+
const node = SVGNode('text', {x: point.x, y: point.y, class: classes}, text);
|
|
1561
|
+
|
|
1562
|
+
switch (align) {
|
|
1563
|
+
case 'right':
|
|
1564
|
+
node.style.textAnchor = 'end';
|
|
1565
|
+
break;
|
|
1566
|
+
|
|
1567
|
+
case 'center':
|
|
1568
|
+
node.style.textAnchor = 'middle';
|
|
1569
|
+
break;
|
|
1570
|
+
|
|
1571
|
+
default:
|
|
1572
|
+
node.style.textAnchor = 'start';
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
(target||this.group).appendChild(node);
|
|
1577
|
+
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* render a donut, given a list of slices (as %)
|
|
1582
|
+
* @param values
|
|
1583
|
+
*/
|
|
1584
|
+
public RenderDonut(
|
|
1585
|
+
slices: DonutSlice[],
|
|
1586
|
+
center: Point,
|
|
1587
|
+
outer_radius: number,
|
|
1588
|
+
inner_radius: number,
|
|
1589
|
+
bounds_area: Area,
|
|
1590
|
+
callouts: boolean,
|
|
1591
|
+
classes?: string | string[]): void {
|
|
1592
|
+
|
|
1593
|
+
let start_angle = -Math.PI / 2; // start at 12:00
|
|
1594
|
+
let end_angle = 0;
|
|
1595
|
+
|
|
1596
|
+
if (callouts) {
|
|
1597
|
+
outer_radius *= .8;
|
|
1598
|
+
inner_radius *= .7;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const PointOnCircle = (center: Point, radius: number, angle: number) => {
|
|
1602
|
+
return [
|
|
1603
|
+
Math.cos(angle) * radius + center.x,
|
|
1604
|
+
Math.sin(angle) * radius + center.y,
|
|
1605
|
+
];
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
for (const slice of slices) {
|
|
1609
|
+
|
|
1610
|
+
const title = slice.title || '';
|
|
1611
|
+
|
|
1612
|
+
const value = slice.percent;
|
|
1613
|
+
const index = slice.index;
|
|
1614
|
+
|
|
1615
|
+
let d: string[] = [];
|
|
1616
|
+
|
|
1617
|
+
let half_angle = 0;
|
|
1618
|
+
|
|
1619
|
+
const outer = PointOnCircle.bind(0, center, outer_radius);
|
|
1620
|
+
const inner = PointOnCircle.bind(0, center, inner_radius);
|
|
1621
|
+
|
|
1622
|
+
if (value > 0.5) {
|
|
1623
|
+
// split into two segments
|
|
1624
|
+
|
|
1625
|
+
half_angle = start_angle + (value / 2) * Math.PI * 2;
|
|
1626
|
+
end_angle = start_angle + value * Math.PI * 2;
|
|
1627
|
+
|
|
1628
|
+
const delta1 = half_angle - start_angle;
|
|
1629
|
+
const delta2 = end_angle - half_angle;
|
|
1630
|
+
|
|
1631
|
+
d.push(
|
|
1632
|
+
`M${outer(start_angle)}`,
|
|
1633
|
+
`A${outer_radius},${outer_radius},${delta1},0,1,${outer(half_angle)}`,
|
|
1634
|
+
`A${outer_radius},${outer_radius},${delta2},0,1,${outer(end_angle)}`,
|
|
1635
|
+
`L${inner(end_angle)}`,
|
|
1636
|
+
`A${inner_radius},${inner_radius},${delta2},0,0,${inner(half_angle)}`,
|
|
1637
|
+
`A${inner_radius},${inner_radius},${delta1},0,0,${inner(start_angle)}`,
|
|
1638
|
+
'Z');
|
|
1639
|
+
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
|
|
1643
|
+
end_angle = start_angle + value * Math.PI * 2;
|
|
1644
|
+
half_angle = (end_angle - start_angle) / 2 + start_angle;
|
|
1645
|
+
|
|
1646
|
+
const delta = end_angle - start_angle;
|
|
1647
|
+
d.push(
|
|
1648
|
+
`M${outer(start_angle)}`,
|
|
1649
|
+
`A${outer_radius},${outer_radius},${delta},0,1,${outer(end_angle)}`,
|
|
1650
|
+
`L${inner(end_angle)}`,
|
|
1651
|
+
`A${inner_radius},${inner_radius},${delta},0,0,${inner(start_angle)}`,
|
|
1652
|
+
'Z');
|
|
1653
|
+
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const node = SVGNode('path', {
|
|
1657
|
+
d, class: (typeof index === 'undefined' ? undefined : `series-${index}`)
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
if (typeof index !== 'undefined') {
|
|
1661
|
+
node.setAttribute('data-index', index.toString());
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/*
|
|
1665
|
+
if (title) {
|
|
1666
|
+
node.addEventListener('mouseenter', (event) => {
|
|
1667
|
+
this.parent.setAttribute('title', title);
|
|
1668
|
+
});
|
|
1669
|
+
node.addEventListener('mouseleave', (event) => {
|
|
1670
|
+
this.parent.setAttribute('title', '');
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
*/
|
|
1674
|
+
|
|
1675
|
+
// we're creating a containing group so that we can nth-child the slices,
|
|
1676
|
+
// otherwise they'll be in the same group as the title
|
|
1677
|
+
|
|
1678
|
+
const donut = SVGNode('g', {class: classes});
|
|
1679
|
+
|
|
1680
|
+
donut.appendChild(node);
|
|
1681
|
+
this.group.appendChild(donut);
|
|
1682
|
+
|
|
1683
|
+
if (/*callouts &&*/ value >= .05 && title) {
|
|
1684
|
+
|
|
1685
|
+
const length = outer_radius - inner_radius;
|
|
1686
|
+
d = [];
|
|
1687
|
+
|
|
1688
|
+
const anchor = PointOnCircle(center,
|
|
1689
|
+
inner_radius + (outer_radius - inner_radius) / 2 + length, half_angle);
|
|
1690
|
+
|
|
1691
|
+
d.push(`M${PointOnCircle(center, inner_radius + (outer_radius - inner_radius) / 2, half_angle)}`);
|
|
1692
|
+
d.push(`L${anchor}`);
|
|
1693
|
+
|
|
1694
|
+
/*
|
|
1695
|
+
const callout = document.createElementNS(SVGNS, 'path');
|
|
1696
|
+
callout.setAttribute('d', d.join(' '));
|
|
1697
|
+
callout.setAttribute('class', 'callout');
|
|
1698
|
+
donut.appendChild(callout);
|
|
1699
|
+
*/
|
|
1700
|
+
donut.appendChild(SVGNode('path', { d, class: 'callout' }));
|
|
1701
|
+
|
|
1702
|
+
const text_parts: string[] = [];
|
|
1703
|
+
const callout_label = SVGNode('text', {class: 'callout-label'});
|
|
1704
|
+
donut.appendChild(callout_label);
|
|
1705
|
+
|
|
1706
|
+
const corrected = half_angle + Math.PI / 2;
|
|
1707
|
+
const text = title;
|
|
1708
|
+
|
|
1709
|
+
callout_label.textContent = text;
|
|
1710
|
+
let bounds = callout_label.getBoundingClientRect();
|
|
1711
|
+
const metrics = {
|
|
1712
|
+
width: bounds.width,
|
|
1713
|
+
height: bounds.height,
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
// const metrics = this.MeasureText(text, ['donut', 'callout-label']);
|
|
1717
|
+
|
|
1718
|
+
let [x, y] = anchor;
|
|
1719
|
+
|
|
1720
|
+
x += metrics.height / 2 * Math.cos(half_angle);
|
|
1721
|
+
y += metrics.height / 4 + metrics.height / 2 * Math.sin(half_angle);
|
|
1722
|
+
|
|
1723
|
+
let try_break = false;
|
|
1724
|
+
|
|
1725
|
+
if (corrected > Math.PI) {
|
|
1726
|
+
if (x - metrics.width <= bounds_area.left) {
|
|
1727
|
+
try_break = true;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
else {
|
|
1731
|
+
if (x + metrics.width > bounds_area.right) {
|
|
1732
|
+
try_break = true;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// this breaks numbers, bad!
|
|
1737
|
+
|
|
1738
|
+
// const break_regex = /[\s-\W]/;
|
|
1739
|
+
const break_regex = /[\s-]/;
|
|
1740
|
+
|
|
1741
|
+
if (try_break && break_regex.test(text)) {
|
|
1742
|
+
let break_index = -1;
|
|
1743
|
+
let break_value = 1;
|
|
1744
|
+
|
|
1745
|
+
const indices: number[] = [];
|
|
1746
|
+
for (let i = 0; i < text.length; i++) {
|
|
1747
|
+
if (break_regex.test(text[i])) {
|
|
1748
|
+
const index_value = Math.abs(0.5 - (i / text.length));
|
|
1749
|
+
if (index_value < break_value) {
|
|
1750
|
+
break_value = index_value;
|
|
1751
|
+
break_index = i;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (break_index > 0) {
|
|
1757
|
+
text_parts.push(text.substr(0, break_index + 1).trim());
|
|
1758
|
+
text_parts.push(text.substr(break_index + 1).trim());
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
else {
|
|
1762
|
+
// ... ellipsis?
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/*
|
|
1766
|
+
if (y <= bounds_area.top) {
|
|
1767
|
+
console.info("break top", title, y);
|
|
1768
|
+
}
|
|
1769
|
+
if (y >= bounds_area.bottom) {
|
|
1770
|
+
console.info("break bottom", title, y);
|
|
1771
|
+
}
|
|
1772
|
+
*/
|
|
1773
|
+
|
|
1774
|
+
if (text_parts.length) {
|
|
1775
|
+
let dy = 0;
|
|
1776
|
+
let widest = 0;
|
|
1777
|
+
|
|
1778
|
+
const parts = text_parts.map((part) => {
|
|
1779
|
+
callout_label.textContent = part;
|
|
1780
|
+
bounds = callout_label.getBoundingClientRect();
|
|
1781
|
+
const m = {
|
|
1782
|
+
width: bounds.width,
|
|
1783
|
+
height: bounds.height,
|
|
1784
|
+
};
|
|
1785
|
+
//const m = this.MeasureText(part, ['donut', 'callout-label']);
|
|
1786
|
+
widest = Math.max(widest, m.width);
|
|
1787
|
+
return { text: part, metrics: m };
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
// console.info('p', parts);
|
|
1791
|
+
|
|
1792
|
+
callout_label.textContent = '';
|
|
1793
|
+
for (const part of parts) {
|
|
1794
|
+
const tspan = document.createElementNS(SVGNS, 'tspan');
|
|
1795
|
+
tspan.textContent = part.text;
|
|
1796
|
+
|
|
1797
|
+
const part_x = (corrected > Math.PI) ?
|
|
1798
|
+
(x - (widest - part.metrics.width) / 2) :
|
|
1799
|
+
(x + (widest - part.metrics.width) / 2);
|
|
1800
|
+
|
|
1801
|
+
tspan.setAttribute('x', part_x.toString());
|
|
1802
|
+
tspan.setAttribute('dy', dy.toString());
|
|
1803
|
+
|
|
1804
|
+
callout_label.appendChild(tspan);
|
|
1805
|
+
dy = part.metrics.height;
|
|
1806
|
+
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
else {
|
|
1810
|
+
// already in from measurement
|
|
1811
|
+
// callout_label.textContent = title;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const text_anchor = corrected > Math.PI ? 'end' : 'start';
|
|
1815
|
+
callout_label.setAttribute('text-anchor', text_anchor);
|
|
1816
|
+
callout_label.setAttribute('x', x.toString());
|
|
1817
|
+
callout_label.setAttribute('y', y.toString());
|
|
1818
|
+
|
|
1819
|
+
if (typeof index !== 'undefined') {
|
|
1820
|
+
callout_label.setAttribute('data-index', index.toString());
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
start_angle = end_angle;
|
|
1827
|
+
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/*
|
|
1834
|
+
protected PointOnCircle(angle: number, center: Point, radius: number) {
|
|
1835
|
+
return [
|
|
1836
|
+
Math.cos(angle) * radius + center.x,
|
|
1837
|
+
Math.sin(angle) * radius + center.y,
|
|
1838
|
+
];
|
|
1839
|
+
}
|
|
1840
|
+
*/
|
|
1841
|
+
}
|