/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
import type { Size, Point } from './rectangle';
import { Area } from './rectangle';
import type { BoxPlotData, DonutSlice, LegendOptions, SeriesType} from './chart-types';
import { LegendLayout, LegendPosition, LegendStyle } from './chart-types';
import type { RangeScale } from 'treb-utils';
const SVGNS = 'http://www.w3.org/2000/svg';
export interface Metrics {
width: number;
height: number;
y_offset: number;
}
const SVGNode = (tag: string, attribute_map: {[index: string]: string|number|unknown} = {}, text?: string): SVGElement => {
const node = document.createElementNS(SVGNS, tag);
for (const key of Object.keys(attribute_map)) {
if (attribute_map[key] !== undefined) {
const value = attribute_map[key] ?? '';
node.setAttribute(key, Array.isArray(value) ? value.join(' ') : value.toString());
}
}
if (text) { node.textContent = text; }
return node;
};
/**
* FIXME: normalize API, make canvas version
*/
export class ChartRenderer {
public parent!: HTMLElement;
public svg_node!: SVGElement;
public text_measurement_node?: SVGTextElement;
public container_group: SVGGElement;
public group: SVGGElement;
public axis_group: SVGGElement;
public label_group: SVGGElement;
public size: Size = { width: 0, height: 0 };
public bounds: Area = new Area();
// public smoothing_factor = 0.2;
constructor() {
this.container_group = SVGNode('g') as SVGGElement;
this.group = SVGNode('g') as SVGGElement;
this.axis_group = SVGNode('g', {class: 'axis-group'}) as SVGGElement;
this.label_group = SVGNode('g', {class: 'label-group'}) as SVGGElement;
this.container_group.appendChild(this.axis_group);
this.container_group.appendChild(this.group);
this.container_group.appendChild(this.label_group);
}
public Initialize(node: HTMLElement): void {
this.parent = node;
this.svg_node = SVGNode('svg', {
class: 'treb-chart',
// style: 'overflow: hidden; position: relative; width: 100%; height: 100%;'
});
this.svg_node.style.overflow = 'hidden';
this.svg_node.style.position = 'relative';
this.svg_node.style.width = '100%';
this.svg_node.style.height = '100%';
this.svg_node.appendChild(this.container_group);
// FIXME: validate parent is relative/absolute
this.parent.appendChild(this.svg_node);
this.Resize();
}
public Legend(options: LegendOptions): void {
const group = SVGNode('g');
this.group.appendChild(group);
const measure = SVGNode('text');
group.appendChild(measure);
// IE says no
// group.classList.add('legend');
group.setAttribute('class', 'legend');
const rows: number[][] = [[]];
const padding = 10;
let space = options.area.width;
let row = 0;
let max_height = 0;
const width = options.area.width;
const marker_width = (options.style === LegendStyle.marker) ? 14 : 26;
const metrics = options.labels.map((label, index) => {
measure.textContent = label.label;
const text_rect = measure.getBoundingClientRect();
const text_metrics = { width: text_rect.width, height: text_rect.height };
const composite = text_metrics.width + marker_width + padding;
max_height = Math.max(max_height, text_metrics.height);
if (options.layout === LegendLayout.vertical) {
rows[index] = [index];
}
else {
if (composite > space) {
if (rows[row].length === 0) {
// there's nothing in this row, so moving to the next
// row will not help; stick it in here regardless
rows[row].push(index);
row++;
rows[row] = [];
space = width;
}
else {
row++;
rows[row] = [index];
space = width - composite;
}
}
else {
rows[row].push(index);
space -= composite;
}
}
return text_metrics;
});
// IE11: SVG element doesn't have parent element? (...)
// measure.parentElement?.removeChild(measure);
group.removeChild(measure);
let y = max_height;
let layout = options.layout || LegendLayout.horizontal;
if (layout === LegendLayout.horizontal && rows.every(row => row.length <= 1)) {
layout = LegendLayout.horizontal;
}
for (let row = 0; row < rows.length; row++) {
const row_width = rows[row].reduce((a, x) => a + metrics[x].width + marker_width, (rows[row].length - 1) * padding);
let h = 0;
let x = layout === LegendLayout.horizontal ?
Math.round((width - row_width) / 2) :
Math.round(padding / 2);
for (let col = 0; col < rows[row].length; col++) {
const index = rows[row][col];
const text_metrrics = metrics[index];
const label = options.labels[index];
const marker_y = y - 1; // Math.round(y + text_metrrics.height / 2);
// NOTE: trident offset is inlined here
let trident = false;
if (typeof navigator !== 'undefined') {
trident = /trident/i.test(navigator?.userAgent || '');
}
const color = typeof label.index === 'number' ? label.index : index + 1;
group.appendChild(SVGNode('text', {
'dominant-baseline': 'middle', x: x + marker_width, y, dy: (trident ? '.3em' : undefined) }, label.label));
switch (options.style) {
case LegendStyle.marker:
group.appendChild(SVGNode('rect', {
class: `series-${color}`, x, y: marker_y - 4, width: 8, height: 8 }));
break;
case LegendStyle.bubble:
group.appendChild(SVGNode('circle', {
class: `series-${color}`, cx: x + marker_width - 11, cy: marker_y - 4 + 3, /* r: '0.25em' */ }));
break;
default:
group.appendChild(SVGNode('rect', {
class: `series-${color}`, x, y: marker_y - 1, width: marker_width - 3, height: 2}));
break;
}
h = Math.max(h, text_metrrics.height);
x += text_metrrics.width + marker_width + padding;
}
y = Math.round(y + h * 1.1);
}
const rect = group.getBoundingClientRect();
const legend_size = { width: rect.width, height: rect.height + max_height };
switch (options.position) {
case LegendPosition.bottom:
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.bottom - legend_size.height})`);
break;
case LegendPosition.left:
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.top})`);
break;
case LegendPosition.right:
group.setAttribute('transform', `translate(${options.area.right - legend_size.width}, ${options.area.top})`);
break;
case LegendPosition.top:
default:
group.setAttribute('transform', `translate(${options.area.left}, ${options.area.top})`);
}
if (options.position === LegendPosition.top) {
options.area.top += legend_size.height || 0;
}
else if (options.position === LegendPosition.right) {
options.area.right -= ((legend_size.width || 0) + 8); // 8?
}
else if (options.position === LegendPosition.left) {
options.area.left += ((legend_size.width || 0) + 8);
}
else {
options.area.bottom -= legend_size.height || 0;
}
// return legend_size;
}
public Clear(class_name?: string): void {
this.group.textContent = '';
this.axis_group.textContent = '';
this.label_group.textContent = '';
class_name = 'treb-chart' + (class_name ? ' ' + class_name: '');
this.svg_node.setAttribute('class', class_name);
}
public Resize(): void {
const bounds = this.svg_node.getBoundingClientRect();
this.svg_node.setAttribute('width', bounds.width.toString());
this.svg_node.setAttribute('height', bounds.height.toString());
this.size = {
width: bounds.width,
height: bounds.height,
};
}
/**
* initialize before render. this assumes that document layout/scroll
* won't change during the render pass, so we can cache some values.
*/
public Prerender(): void {
const bounds = this.svg_node.getBoundingClientRect();
this.bounds.top = bounds.top;
this.bounds.left = bounds.left;
this.bounds.right = bounds.right;
this.bounds.bottom = bounds.bottom;
}
/**
* render title. this method modifies "area" in place -- that's
* the style we want to use going forward.
*
* @param title
* @param area
* @param margin
* @param layout
*/
public RenderTitle(
title: string,
area: Area,
margin: number,
layout: 'top'|'bottom'): void {
const text = SVGNode('text', {
class: 'chart-title',
x: Math.round(area.width / 2),
// style: 'text-anchor: middle',
}, title);
text.style.textAnchor = 'middle';
this.group.appendChild(text);
const bounds = text.getBoundingClientRect();
switch (layout) {
case 'bottom':
text.setAttribute('y', Math.round(area.bottom - bounds.height).toString());
area.bottom -= (bounds.height + margin);
break;
default:
text.setAttribute('y', Math.round(area.top + margin + bounds.height).toString());
area.top += (bounds.height + margin);
break;
}
}
/**
* measure a label, optionally with class name(s)
*
* this is silly. you are doing the measurement on a random node and
* trying to match classes, while you could just do the measurement on
* the actual node, get actual classes right, and not bother with junk
* nodes.
*
* FIXME: decprecate
*
*/
public MeasureText(label: string, classes?: string | string[], ceil = false): Metrics {
if (!this.text_measurement_node) {
this.text_measurement_node = SVGNode('text', { x: '-100px', y: '-100px' }) as SVGTextElement;
this.svg_node.appendChild(this.text_measurement_node);
}
if (typeof classes !== 'undefined') {
if (typeof classes === 'string') {
classes = [classes];
}
this.text_measurement_node.setAttribute('class', classes.join(' '));
}
else {
this.text_measurement_node.setAttribute('class', '');
}
this.text_measurement_node.textContent = label;
const bounds = this.text_measurement_node.getBoundingClientRect();
const metrics = {
width: bounds.width,
height: bounds.height,
// wtf is this?
y_offset: bounds.height - ((this.bounds.top - bounds.top) - 100),
};
if (ceil) {
metrics.width = Math.ceil(metrics.width);
metrics.height = Math.ceil(metrics.height);
metrics.y_offset = Math.ceil(metrics.y_offset);
}
return metrics;
}
public RenderTicks(area: Area,
top: number, bottom: number, count: number, classes?: string | string[]) {
const d: string[] = [];
const step = area.width / (count);
for (let i = 0; i < count; i++) {
const center = Math.round(area.left + step / 2 + step * i) - 0.5;
d.push(`M${center} ${top} L${center} ${bottom}`);
}
this.group.appendChild(SVGNode('path', {d, class: classes}));
}
/*
public GetAxisNode(): SVGElement {
if (!this.axis_group) {
this.axis_group = SVGNode('g', {class: 'axis-group'});
this.group.appendChild(this.axis_group);
}
return this.axis_group;
}
*/
/** specialization for bar; it's different enough that we want special treatment */
public RenderXAxisBar(
area: Area,
offset: boolean,
labels: string[],
metrics: Metrics[],
classes?: string | string[]): void {
const count = labels.length;
if (!count) return;
// FIXME: base on font, ' ' character
const label_buffer = 4;
const step = offset ? area.width / count : area.width / (count - 1);
const initial_offset = offset ? (step / 2) : 0;
// calculate increment (skip_count)
let increment = 1;
let repeat = true;
while (repeat) {
repeat = false;
let extent = 0;
for (let i = 0; i < count; i += increment) {
const center = Math.round(area.left + initial_offset + step * i);
const left = center - metrics[i].width / 2;
if (extent && (left <= extent)) {
increment++;
repeat = true;
break;
}
// FIXME: buffer? they get pretty tight sometimes
extent = center + (metrics[i].width / 2) + label_buffer;
}
}
// const axis = this.GetAxisNode();
for (let i = 0; i < count; i += increment) {
const x = Math.round(area.left + initial_offset + step * i);
// if (x + metrics[i].width / 2 >= area.right) { break; }
this.RenderText(this.axis_group, labels[i], 'center', { x, y: area.bottom }, classes);
}
}
public RenderXAxisTicks(area: Area, offset: boolean, count: number): void {
const step = offset ? area.width / count : area.width / (count - 1);
const initial_offset = offset ? (step / 2) : 0;
const d: string[] = [];
for (let i = 0; i < count; i++) {
const center = Math.round(area.left + initial_offset + step * i) + .5;
d.push(`M${center},${area.bottom + .5} v${6}`)
}
this.axis_group.appendChild(SVGNode('path', {d: d.join(' '), class: 'x-axis-tick axis-tick'}));
}
/**
* render x axis labels; skips over labels to prevent overlap
*
* @param offset - move label by 1/2 step width, to center it under columns.
*/
public RenderXAxis(
area: Area,
offset: boolean,
labels: string[],
metrics: Metrics[],
classes?: string | string[]): void {
const count = labels.length;
if (!count) return;
// FIXME: base on font, ' ' character
const label_buffer = 4;
const step = offset ? area.width / count : area.width / (count - 1);
// const initial_offset = shift ? (step / 2) : 0;
const initial_offset = offset ? (step / 2) : 0;
// calculate increment (skip_count)
let increment = 1;
let repeat = true;
const f2 = (labels.length - 1) % 2 === 0;
const f3 = (labels.length - 1) % 3 === 0;
// const f5 = (labels.length - 1) % 5 === 0;
while (repeat) {
repeat = false;
let extent = 0;
for (let i = 0; i < count; i += increment) {
const center = Math.round(area.left + initial_offset + step * i);
const left = center - metrics[i].width / 2;
if (extent && (left <= extent)) {
increment++;
repeat = true;
break;
}
// FIXME: buffer? they get pretty tight sometimes
extent = center + (metrics[i].width / 2) + label_buffer;
}
}
// special patch for 0% - 100% range...
if (increment === 3 && !f3 && f2) {
increment++;
}
// const axis = this.GetAxisNode();
for (let i = 0; i < count; i += increment) {
const x = Math.round(area.left + initial_offset + step * i);
// if (x + metrics[i].width / 2 >= area.right) { break; }
this.RenderText(this.axis_group, labels[i], 'center', { x, y: area.bottom }, classes);
}
}
/** specialization for bar; it's different enough that we want special treatment */
public RenderYAxisBar(area: Area, left: number,
labels: Array<{
label: string;
metrics: Metrics;
}>, classes?: string | string[]) {
labels = labels.slice(0);
labels.reverse();
const count = labels.length;
if (!count) return;
const step = area.height / count;
// calculate increment (skip count)
let increment = 1;
let repeat = true;
while (repeat) {
repeat = false;
let extent = 0;
for (let i = 0; i < count; i += increment) {
const label = labels[i];
const y = Math.round(area.bottom - step * (i + .5) + label.metrics.height / 4);
if (extent && y >= extent) {
increment++;
repeat = true;
break;
}
extent = y - label.metrics.height;
}
}
// const axis = this.GetAxisNode();
for (let i = 0; i < count; i += increment) {
const label = labels[i];
const y = Math.round(area.bottom - step * (i + .5) + label.metrics.height / 4);
this.RenderText(this.axis_group, label.label, 'right', { x: left, y }, classes);
}
}
/**
* render y axis labels; skips over labels to prevent overlap
*/
public RenderYAxis(area: Area, x: number,
labels: Array<{
label: string;
metrics: Metrics;
}>, classes?: string | string[], align:'left'|'right' = 'right') {
const count = labels.length;
if (!count) return;
const step = area.height / (count - 1);
// calculate increment (skip count)
let increment = 1;
let repeat = true;
while (repeat) {
repeat = false;
let extent = 0;
for (let i = 0; i < count; i += increment) {
const label = labels[i];
const y = Math.round(area.bottom - step * i + label.metrics.height / 4);
if (extent && y >= extent) {
increment++;
repeat = true;
break;
}
extent = y - label.metrics.height;
}
}
// const axis = this.GetAxisNode();
for (let i = 0; i < count; i += increment) {
const label = labels[i];
const y = Math.round(area.bottom - step * i + label.metrics.height / 4);
this.RenderText(this.axis_group, label.label, align, { x, y }, classes);
}
}
/*
public ControlPoint(current: Point, previous?: Point, next?: Point, reverse = false): Point {
previous = previous || current;
next = next || current;
const o = this.LineProperties(previous, next);
const factor = Math.pow(1 - Math.abs(o.angle) / Math.PI, 2) * this.smoothing_factor;
const angle = o.angle + (reverse ? Math.PI : 0);
const length = o.length * factor;
const x = current.x + Math.cos(angle) * length;
const y = current.y + Math.sin(angle) * length;
return { x, y };
}
*/
public LineProperties(a: Point, b: Point) {
const x = b.x - a.x;
const y = b.y - a.y;
return {
length: Math.sqrt((x * x) + (y * y)),
angle: Math.atan2(y, x),
};
}
public RenderSmoothLine(
area: Area,
data: Array,
fill = false,
titles?: string[],
classes?: string | string[]): void {
const group = SVGNode('g');
const d1: string[] = [];
const d2: string[] = [];
const count = data.length;
const steps = count - 1;
const step = (area.width / count) / 2;
const circles: Array<{
x: number;
y: number;
i: number;
}> = [];
const points: Array = data.map((value, i) => {
if (typeof value === 'undefined') {
return undefined;
}
return {
x: Math.round(area.left + area.width / steps * i),
y: area.bottom - value,
};
});
///
// we need to split into segments in the event of missing data
let segment: Point[] = [];
const render_segment = () => {
if (segment.length < 2){ return; }
let line = '';
const first = segment[0];
const last = segment[segment.length-1];
// note here we're not adding the leading M because for area,
// we want to use an L instead (or it won't be contiguous)
if (segment.length === 2) {
line = `${segment[0].x},${segment[0].y} L${segment[1].x},${segment[1].y}`;
}
else if (segment.length > 2) {
const curve = this.CatmullRomChain(segment);
line = '' + curve.map(point => `${point.x},${point.y}`).join(' L');
}
if (line) {
d1.push('M' + line);
if (fill) {
d2.push(`M ${first.x},${area.bottom} L ${first.x},${first.y}`);
d2.push('L' + line);
d2.push(`L ${last.x},${area.bottom}`);
}
}
};
for (const point of points) {
if (!point) {
render_segment();
segment = [];
}
else {
segment.push(point);
}
}
// render?
if (segment.length) {
render_segment();
}
///
/*
for (let i = 0; i < points.length; i++) {
const point = points[i];
if (point) {
if (move) {
d1.push(`M ${[point.x]},${point.y}`);
if (fill) {
d2.push(`M ${point.x} ${area.bottom} L ${[point.x]},${point.y}`);
}
}
else {
const cp_start = this.ControlPoint(points[i - 1] as Point, points[i - 2], point);
const cp_end = this.ControlPoint(point, points[i - 1], points[i + 1], true);
d1.push(`C ${cp_start.x},${cp_start.y} ${cp_end.x},${cp_end.y} ${point.x},${point.y}`);
d2.push(`C ${cp_start.x},${cp_start.y} ${cp_end.x},${cp_end.y} ${point.x},${point.y}`);
}
move = false;
last_point = point;
}
else {
move = true;
if (fill && last_point) {
d2.push(`L ${last_point.x},${area.bottom} Z`);
}
last_point = undefined;
}
}
if (fill && last_point) {
d2.push(`L ${last_point.x},${area.bottom} Z`);
}
*/
/*
for (; i < count; i++ ){
const point = data[i];
if (typeof point === 'undefined') {
move = true;
if (fill && (typeof last_x !== 'undefined')) {
d2.push(`L${last_x} ${area.bottom}Z`);
}
last_x = undefined;
continue;
}
const x = Math.round(area.left + area.width / steps * i);
if (move) {
if (fill) {
d2.push(`M${x} ${area.bottom} L${x} ${area.bottom - point}`);
}
d1.push(`M${x} ${area.bottom - point}`);
}
else {
d1.push(`L${x} ${area.bottom - point}`);
d2.push(`L${x} ${area.bottom - point}`);
}
circles.push({x, y: area.bottom - point, i});
last_x = x;
move = false;
}
*/
/*
if (fill && (typeof last_x !== 'undefined')) {
d2.push(`L${last_x} ${area.bottom}Z`);
}
*/
// fill first, underneath
if (fill) {
group.appendChild(SVGNode('path', { class: 'fill', d: d2 }));
}
// then line
group.appendChild(SVGNode('path', { class: 'line', d: d1 }));
if (typeof classes !== 'undefined') {
if (typeof classes === 'string') {
classes = [classes];
}
group.setAttribute('class', classes.join(' '));
}
this.group.appendChild(group);
// circles...
if (titles && circles.length) {
const circle_group = SVGNode('g');
for (const circle of circles) {
const shape = SVGNode('circle', {cx: circle.x, cy: circle.y, r: step});
shape.addEventListener('mouseenter', () => {
this.parent.setAttribute('title', titles[circle.i] || '');
});
shape.addEventListener('mouseleave', () => {
this.parent.setAttribute('title', '');
});
circle_group.appendChild(shape);
}
circle_group.setAttribute('class', 'mouse-layer');
this.group.appendChild(circle_group);
}
}
public RenderLine(
area: Area,
data: Array,
fill = false,
titles?: string[],
classes?: string | string[]) {
const group = SVGNode('g');
const d1: string[] = [];
const d2: string[] = [];
const count = data.length;
const steps = count - 1;
const step = (area.width / count) / 2;
const circles: Array<{
x: number;
y: number;
i: number;
}> = [];
let i = 0;
let move = true;
let last_x: number | undefined;
for (; i < count; i++) {
const point = data[i];
if (typeof point === 'undefined') {
move = true;
if (fill && (typeof last_x !== 'undefined')) {
d2.push(`L${last_x} ${area.bottom}Z`);
}
last_x = undefined;
continue;
}
const x = Math.round(/*step*/ + area.left + area.width / steps * i);
if (move) {
if (fill) {
d2.push(`M${x} ${area.bottom} L${x} ${area.bottom - point}`);
}
d1.push(`M${x} ${area.bottom - point}`);
}
else {
d1.push(`L${x} ${area.bottom - point}`);
d2.push(`L${x} ${area.bottom - point}`);
}
circles.push({ x, y: area.bottom - point, i });
last_x = x;
move = false;
}
if (fill && (typeof last_x !== 'undefined')) {
d2.push(`L${last_x} ${area.bottom}Z`);
}
// fill first, underneath
if (fill) {
group.appendChild(SVGNode('path', { class: 'fill', d: d2 }));
}
// then line
group.appendChild(SVGNode('path', { class: 'line', d: d1 }));
if (typeof classes !== 'undefined') {
if (typeof classes === 'string') {
classes = [classes];
}
group.setAttribute('class', classes.join(' '));
}
this.group.appendChild(group);
// circles...
if (titles && circles.length) {
const circle_group = SVGNode('g');
for (const circle of circles) {
const shape = SVGNode('circle', { cx: circle.x, cy: circle.y, r: step });
shape.addEventListener('mouseenter', () => {
this.parent.setAttribute('title', titles[circle.i] || '');
});
shape.addEventListener('mouseleave', () => {
this.parent.setAttribute('title', '');
});
circle_group.appendChild(shape);
}
circle_group.setAttribute('class', 'mouse-layer');
this.group.appendChild(circle_group);
}
}
/**
* the other RenderGrid function has semantics specifically for area/line.
* rather than try to shoehorn this in we'll use a different method.
*/
public RenderBarGrid(area: Area, x_count: number, classes?: string | string[]): void {
const d: string[] = [];
const step = area.width / (x_count);
for (let i = 0; i <= x_count; i++) {
const x = Math.round(area.left + step * i) - 0.5;
d.push(`M${x} ${area.top} L${x} ${area.bottom}`);
}
this.group.appendChild(SVGNode('path', { d, class: classes }));
}
public RenderGrid(area: Area, y_count: number, x_count = 0, classes?: string | string[], zeros?: number[]): void {
const d: string[] = [];
const d2: string[] = [];
let step = area.height / y_count;
for (let i = 0; i <= y_count; i++) {
const y = Math.round(area.top + step * i) - 0.5;
if (zeros && zeros[1] === i) {
d2.push(`M${area.left} ${y} L${area.right} ${y}`);
}
else {
d.push(`M${area.left} ${y} L${area.right} ${y}`);
}
}
step = area.width / (x_count - 1);
for (let i = 0; i < x_count; i++) {
const x = Math.round(area.left + step * i) - 0.5;
if (zeros && zeros[0] === i) {
d2.push(`M${x} ${area.top} L${x} ${area.bottom}`);
}
else {
d.push(`M${x} ${area.top} L${x} ${area.bottom}`);
}
}
this.group.appendChild(SVGNode('path', {d, class: classes}));
if (d2.length) {
if (classes) {
if (!Array.isArray(classes)) {
classes = classes + ' zero';
}
else {
classes.push('zero');
}
}
else {
classes = 'zero';
}
this.group.appendChild(SVGNode('path', {d: d2, class: classes}));
}
}
/* *
* return the intersection point of two lines (assuming
* infinite projection) or undefined if they are parallel
* /
public LineIntersection(a1: Point, a2: Point, b1: Point, b2: Point): Point|undefined {
const det = ((a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x));
if (!det) {
return undefined; // parallel
}
const t = ((a1.x - b1.x) * (b1.y - b2.y) - (a1.y - b1.y) * (b1.x - b2.x)) / det;
return { x: a1.x + t * (a2.x - a1.x), y: a1.y + t * (a2.y - a1.y) };
}
*/
public MultiplyPoint(point: Point, scalar: number): Point {
return {
x: point.x * scalar,
y: point.y * scalar,
};
}
public AddPoints(a: Point, b: Point): Point {
return {
x: a.x + b.x,
y: a.y + b.y,
};
}
/**
* algo from
* https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline
*/
public CatmullRomSpline(P: Point[], n: number): Point[] {
// Parametric constant: 0.5 for the centripetal spline,
// 0.0 for the uniform spline, 1.0 for the chordal spline.
let alpha = .5;
// Premultiplied power constant for the following tj() function.
alpha = alpha/2;
const tj = (ti: number, Pi: Point, Pj: Point) => {
const {x: xi, y: yi} = Pi
const {x: xj, y: yj} = Pj
return ((xj-xi)**2 + (yj-yi)**2)**alpha + ti;
};
const t0 = 0
const t1 = tj(t0, P[0], P[1]);
const t2 = tj(t1, P[1], P[2]);
const t3 = tj(t2, P[2], P[3]);
const step = (t2-t1) / n;
const points: Point[] = [];
for (let i = 0; i < n; i++){
const t = t1 + step * i;
const A1 = this.AddPoints(
this.MultiplyPoint(P[0], (t1-t)/(t1-t0)),
this.MultiplyPoint(P[1], (t-t0)/(t1-t0)),
);
const A2 = this.AddPoints(
this.MultiplyPoint(P[1], (t2-t)/(t2-t1)),
this.MultiplyPoint(P[2], (t-t1)/(t2-t1)),
);
const A3 = this.AddPoints(
this.MultiplyPoint(P[2], (t3-t)/(t3-t2)),
this.MultiplyPoint(P[3], (t-t2)/(t3-t2)),
);
const B1 = this.AddPoints(
this.MultiplyPoint(A1, (t2-t)/(t2-t0)),
this.MultiplyPoint(A2, (t-t0)/(t2-t0)),
);
const B2 = this.AddPoints(
this.MultiplyPoint(A2, (t3-t)/(t3-t1)),
this.MultiplyPoint(A3, (t-t1)/(t3-t1)),
);
const C = this.AddPoints(
this.MultiplyPoint(B1, (t2-t)/(t2-t1)),
this.MultiplyPoint(B2, (t-t1)/(t2-t1)),
);
points.push(C);
}
return points;
}
/**
* NOTE: we are munging the point list here, so don't use it after
* calling this function or pass in a temp copy
*
* OK so that was rude, we will not munge the list
*/
public CatmullRomChain(original: Point[], n = 30): Point[] {
const points = original.slice(0);
const result: Point[] = [];
const len = points.length;
if (len) {
// add two trailing points, extended linearly from existing segmnet
let dx = points[len-1].x - points[len-2].x;
let dy = points[len-1].y - points[len-2].y;
points.push({
x: points[len-1].x + dx,
y: points[len-1].y + dy,
});
points.push({
x: points[len-1].x + dx,
y: points[len-1].y + dy,
});
// some for the first point, in the other direction
dx = points[1].x - points[0].x;
dy = points[1].y - points[0].y;
points.unshift({
x: points[0].x - dx,
y: points[0].y - dy,
});
for (let i = 0; i < points.length - 4; i++) {
const subset = points.slice(i, i + 4);
const step = this.CatmullRomSpline(subset, n);
result.push(...step);
}
}
return result;
}
public RenderDataLabels(
area: Area,
x: Array,
y: Array,
x_scale: RangeScale,
y_scale: RangeScale,
data_labels: Array,
series_index: number ): void {
// const label_group = SVGNode('g');
// this.group.appendChild(label_group);
const count = Math.max(x.length, y.length);
const xrange = (x_scale.max - x_scale.min) || 1;
const yrange = (y_scale.max - y_scale.min) || 1;
for (let i = 0; i < count; i++) {
const a = x[i];
const b = y[i];
if (a !== undefined && b !== undefined) {
const point ={
x: area.left + ((a - x_scale.min) / xrange) * area.width,
y: area.bottom - ((b - y_scale.min) / yrange) * area.height,
};
const label = data_labels[i];
if (label) {
this.label_group.appendChild(SVGNode('circle', {class: 'label-target', cx: point.x, cy: point.y, r: 10 }));
const g = SVGNode('g', {class: 'data-label', transform: `translate(${point.x + 10},${point.y})`});
this.label_group.appendChild(g);
const circle = SVGNode('circle', {
cx: -10, y: 0, r: 5, class: `marker-highlight series-${series_index}`
});
g.appendChild(circle);
const text = SVGNode('text', {x: 4, y: 0}, label);
g.appendChild(text);
const bounds = text.getBoundingClientRect();
const h = bounds.height;
const w = bounds.width + 8;
if (w + 15 + point.x >= area.right) {
g.setAttribute('transform', `translate(${point.x - w - 15},${point.y})`)
circle.setAttribute('cx', (w + 15).toString());
}
const rect = SVGNode('path', {d:`M0,5 h${w} v-${h} h-${w} Z`});
g.insertBefore(rect, text);
}
}
}
}
public RenderBoxAndWhisker(
area: Area,
data: BoxPlotData['data'][0],
index: number,
max_n: number,
scale: RangeScale,
n: number,
classes?: string|string[]){
const group = SVGNode('g', {class: classes});
this.group.appendChild(group);
// space for each box is 1/n of the chart area. we'll allocate margin on a box basis.
const width = area.width / n;
const margin = width * .20; // ??
const box_default_width = Math.min(width - 2 * margin, 90);
let box_width = box_default_width;
if (max_n > 0) {
box_width = box_width * Math.sqrt(data.n) / Math.sqrt(max_n);
}
const Y = (value: number) => area.top + area.height - (value - scale.min) / (scale.max - scale.min) * area.height; // wtf?
const center = area.left + index * width + width / 2;
const q1 = Y(data.quartiles[0]);
const q3 = Y(data.quartiles[2]);
group.appendChild(SVGNode('rect', {
class: `iqr`,
x: area.left + index * width + (width - box_width) / 2,
y: q3,
width: box_width,
height: q1 - q3,
}));
group.appendChild(SVGNode('line', {
class: `median`,
x1: center - box_width * .5,
x2: center + box_width * .5,
y1: Y(data.quartiles[1]),
y2: Y(data.quartiles[1]),
}));
group.appendChild(SVGNode('line', {
class: `whisker`,
x1: center - box_width * .25,
x2: center + box_width * .25,
y1: Y(data.whiskers[0]),
y2: Y(data.whiskers[0]),
}));
group.appendChild(SVGNode('line', {
class: `whisker-extent`,
x1: center,
x2: center,
y1: Y(data.whiskers[0]) - 2,
y2: Y(data.quartiles[0]) + 2,
}));
group.appendChild(SVGNode('line', {
class: `whisker`,
x1: center - box_width * .25,
x2: center + box_width * .25,
y1: Y(data.whiskers[1]),
y2: Y(data.whiskers[1]),
}));
group.appendChild(SVGNode('line', {
class: `whisker-extent`,
x1: center,
x2: center,
y1: Y(data.whiskers[1]) + 2,
y2: Y(data.quartiles[2]) - 2,
}));
for (const point of data.data) {
if (point < data.whiskers[0] || point > data.whiskers[1]) {
group.appendChild(SVGNode('circle', {
class: `outlier`,
cx: center,
cy: Y(point),
r: 3, // default; we can override in css
}));
}
}
}
public RenderBubbleSeries(
area: Area,
series: SeriesType,
x_scale: RangeScale,
y_scale: RangeScale,
classes?: string | string[]): void {
const xrange = (x_scale.max - x_scale.min) || 1;
const yrange = (y_scale.max - y_scale.min) || 1;
// const marker_elements: string[] = [];
const points: Array<{x: number, y: number, z: number} | undefined> = [];
const labels: Array<{
x: number,
y: number,
text: string,
offset: number,
}> = [];
// const d: string[] = [];
// const areas: string[] = [];
const group = SVGNode('g', {class: classes});
// if (title) node.setAttribute('title', title);
this.group.appendChild(group);
if (series.z) {
for (const [index, z] of series.z.data.entries()) {
const x = series.x.data[index];
const y = series.y.data[index];
if (typeof x !== 'undefined' && typeof y !== 'undefined' && typeof z !== 'undefined' && z > 0) {
const size_x = z / xrange * area.width;
const size_y = z / yrange * area.height;
const size = Math.max(size_x, size_y);
const point: Point & { z: number } = {
x: area.left + ((x - x_scale.min) / xrange) * area.width,
y: area.bottom - ((y - y_scale.min) / yrange) * area.height,
z: size,
};
points.push(point);
if (series.labels?.[index]) {
const r = point.z/2;
labels.push({
x: point.x, // + Math.cos(Math.PI/4) * r,
y: point.y, // + Math.sin(Math.PI/4) * r,
text: series.labels?.[index] || '',
offset: Math.cos(Math.PI/4) * r,
});
}
}
}
}
/*
let z_min = z[0] || 0;
let z_max = z[0] || 0;
const map: Map = new Map();
for (let i = 0; i < count; i++) {
const a = x[i];
const b = y[i];
if (typeof a === 'undefined' || typeof b === 'undefined') {
points.push(undefined);
}
else {
const series_key = c[i] || '';
let series = map.get(series_key);
if (typeof series === 'undefined') {
series = map.size + 1;
map.set(series_key, series);
}
let size = z[i] || 0;
if (size) {
const size_x = size / xrange * area.width;
const size_y = size / yrange * area.height;
size = Math.min(size_x, size_y);
}
points.push({
x: area.left + ((a - x_scale.min) / xrange) * area.width,
y: area.bottom - ((b - y_scale.min) / yrange) * area.height,
z: size,
series,
});
}
}
*/
for (const point of points) {
if (point) {
group.appendChild(SVGNode('circle', {
cx: point.x,
cy: point.y,
r: point.z / 2,
class: `point`,
}));
}
}
if (labels.length) {
const container = this.label_group.getBoundingClientRect();
for (const entry of labels) {
if (entry.text) {
const group = this.label_group.appendChild(SVGNode('g', {
class: 'bubble-label',
}));
const rect = group.appendChild(SVGNode('rect', {
x: entry.x, // + entry.offset,
y: entry.y, // + entry.offset,
// rx: `3px`,
// fill: 'Canvas',
// 'fill-opacity': '60%',
// stroke: `none`,
// 'style': `--translate-offset: ${Math.round(entry.offset)}px`,
class: 'label-background'
}));
const label = group.appendChild(SVGNode('text', {
x: entry.x, // + entry.offset,
y: entry.y, // + entry.offset,
offset: entry.offset,
class: 'label-text',
'text-anchor': 'middle',
'alignment-baseline': 'middle',
'style': `--translate-offset: ${Math.round(entry.offset)}px`,
}, entry.text));
const bounds = label.getBoundingClientRect();
rect.setAttribute('x', (bounds.left - container.left - 2).toString());
rect.setAttribute('y', (bounds.top - container.top - 1).toString());
rect.style.height = (bounds.height + 2) + `px`;
rect.style.width = (bounds.width + 4) + `px`;
}
}
}
}
public RenderScatterSeries(area: Area,
x: Array,
y: Array,
x_scale: RangeScale,
y_scale: RangeScale,
lines = true,
plot_points = false,
filled = false,
markers = false,
smooth = false,
classes?: string | string[]): void {
// ...
const count = Math.max(x.length, y.length);
const xrange = (x_scale.max - x_scale.min) || 1;
const yrange = (y_scale.max - y_scale.min) || 1;
// const marker_elements: string[] = [];
const points: Array = [];
const d: string[] = [];
const areas: string[] = [];
const group = SVGNode('g', {class: classes});
// if (title) node.setAttribute('title', title);
this.group.appendChild(group);
for (let i = 0; i < count; i++) {
const a = x[i];
const b = y[i];
if (typeof a === 'undefined' || typeof b === 'undefined') {
points.push(undefined);
}
else {
points.push({
x: area.left + ((a - x_scale.min) / xrange) * area.width,
y: area.bottom - ((b - y_scale.min) / yrange) * area.height,
});
}
}
// FIXME: merge loops, if possible
/*
if (markers) {
for (const point of points) {
if (point) {
// if we can't use CSS to update the path (except in chrome)
// then it's probably not worth it... leave it for now
// marker_elements.push(``);
}
}
}
*/
if (lines) {
// we need to split into segments in the event of missing data
let segment: Point[] = [];
const render_segment = smooth ? () => {
// segments < 3 should be straight lines (or points)
if (segment.length === 2) {
return `${segment[0].x},${segment[0].y} L${segment[1].x},${segment[1].y}`;
}
else if (segment.length > 2) {
const curve = this.CatmullRomChain(segment);
return curve.map(point => `${point.x},${point.y}`).join(' L');
}
return '';
} : () => {
return segment.map(point => `${point.x},${point.y}`).join(' L');
};
for (const point of points) {
if (!point) {
if (segment.length >= 2) {
const line = render_segment();
d.push('M' + line);
areas.push(`M${segment[0].x},${area.bottom}L` + line + `L${segment[segment.length - 1].x},${area.bottom}Z`);
}
segment = [];
}
else {
segment.push(point);
}
}
if (segment.length >= 2) {
const line = render_segment();
d.push('M' + line);
areas.push(`M${segment[0].x},${area.bottom}L` + line + `L${segment[segment.length - 1].x},${area.bottom}Z`);
}
}
if (filled) {
group.appendChild(SVGNode('path', {d: areas, class: 'fill'}));
}
if (lines) {
group.appendChild(SVGNode('path', {d, class: 'line'}));
}
if (plot_points) {
for (const point of points) {
if (point) {
group.appendChild(SVGNode('circle', {cx: point.x, cy: point.y, r: 1, class: 'point'}));
// if we can't use CSS to update the path (except in chrome)
// then it's probably not worth it... leave it for now
// marker_elements.push(``);
}
}
}
if (markers) {
for (const point of points) {
if (point) {
group.appendChild(SVGNode('circle', {cx: point.x, cy: point.y, r: 3, class: 'marker'}));
// if we can't use CSS to update the path (except in chrome)
// then it's probably not worth it... leave it for now
// marker_elements.push(``);
}
}
}
// SetSVG(group, `${marker_elements.join('')}`);
}
public RenderPoints(area: Area, x: number[], y: number[], classes?: string | string[]) {
const d: string[] = [];
for (let i = 0; i < x.length; i++) {
const px = x[i] * area.width + area.left;
const py = area.bottom - y[i] * area.height;
d.push(`M${px - 1},${py - 1} L${px + 1},${py + 1}`);
d.push(`M${px - 1},${py + 1} L${px + 1},${py - 1}`);
}
this.group.appendChild(SVGNode('path', {d, class: classes}));
}
public RenderPoint(cx: number, cy: number, classes?: string | string[]): void {
this.group.appendChild(SVGNode('circle', {cx, cy, r: 1, class: classes}));
}
public RenderCalloutLines(lines: Array<{x1: number, y1: number, x2: number, y2:number, label?: string, classes?: string }>): void {
const g = SVGNode('g', {class: 'callouts'});
this.label_group.appendChild(g);
for (const line of lines) {
g.appendChild(SVGNode('path', {
d: `M${line.x1},${line.y1} L${line.x2},${line.y2}`,
class: 'callout ' + (line.classes || '').trim(),
}));
}
}
public RenderRectangle(
area: Area,
corner_radius?: number[],
classes?: string | string[],
title?: string,
label?: string,
label_point?: Point): void {
let d = '';
if (corner_radius) {
// two cases we have to worry about: top L/R corner radius > height,
// and top/bottom L radius > width
if (corner_radius[0] &&
corner_radius[0] === corner_radius[1] &&
corner_radius[0] >= area.height) {
const c = corner_radius[0];
const b = corner_radius[0] - area.height;
const a = Math.sqrt(c * c - b * b);
d = `M${area.left + area.width / 2 - a},${area.bottom} a${c},${c} 0 0 1 ${a * 2},0 z`;
}
else if (corner_radius[1] &&
corner_radius[1] === corner_radius[2] &&
corner_radius[1] >= area.width) {
const c = corner_radius[1];
const b = corner_radius[1] - area.width;
const a = Math.sqrt(c * c - b * b);
d = `M${area.left},${area.top + area.height / 2 - a} a${c},${c} 0 0 1 0,${a * 2} z`;
}
else {
d = `M${area.left},${area.top + corner_radius[0]} `
+ `a${corner_radius[0]},${corner_radius[0]} 0 0 1 ${corner_radius[0]},${-corner_radius[0]} `
+ `h${area.width - corner_radius[0] - corner_radius[1]} `
+ `a${corner_radius[1]},${corner_radius[1]} 0 0 1 ${corner_radius[1]},${corner_radius[1]} `
+ `v${area.height - corner_radius[1] - corner_radius[2]} `
+ `a${corner_radius[2]},${corner_radius[2]} 0 0 1 ${-corner_radius[2]},${corner_radius[2]} `
+ `h${-area.width + corner_radius[2] + corner_radius[3]} `
+ `a${corner_radius[3]},${corner_radius[3]} 0 0 1 ${-corner_radius[3]},${-corner_radius[3]} `
+ `v${-area.height + corner_radius[3] + corner_radius[0]} `;
}
}
else {
/*
node = SVGNode('rect', {
x: area.left,
y: area.top,
width: area.width,
height: area.height,
class: classes });
*/
d = `M${area.left},${area.top} `
+ `h${area.width} `
+ `v${area.height} `
+ `h${-area.width} `
+ `v${-area.height} `;
}
const node = SVGNode('path', {
d, class: classes,
});
if (title) {
node.addEventListener('mouseenter', () => {
this.parent.setAttribute('title', title);
});
node.addEventListener('mouseleave', () => {
this.parent.setAttribute('title', '');
});
}
this.group.appendChild(node);
if (label) {
this.label_group.appendChild(SVGNode('path', {class: 'label-target', d }));
const point = label_point || {
x: Math.round(area.left + area.width / 2),
y: Math.round(area.top - 10),
};
const g = SVGNode('g', {class: 'data-label', transform: `translate(${point.x},${point.y})`});
this.label_group.appendChild(g);
const text = SVGNode('text', {x: 0, y: 0}, label);
g.appendChild(text);
const bounds = text.getBoundingClientRect();
const h = bounds.height;
const w = bounds.width + 8;
if (point.y - bounds.height < 4) {
point.y -= (point.y - bounds.height - 4);
g.setAttribute('transform', `translate(${point.x},${point.y})`);
}
text.setAttribute('x', Math.floor(-bounds.width/2).toString());
/*
if (w + 15 + point.x >= area.right) {
g.setAttribute('transform', `translate(${point.x - w - 15},${point.y})`)
// circle.setAttribute('cx', (w + 15).toString());
}
*/
const vertical_padding = Math.ceil(h * .125);
// const rect = SVGNode('path', {d:`M${-w/2},${vertical_padding} h${w} v-${h + vertical_padding / 2} h-${w} Z`});
const rect = SVGNode('rect', {rx: 3, x: -w/2, y: Math.round(-h + vertical_padding * 2/3), width: w, height: h + vertical_padding});
g.insertBefore(rect, text);
}
}
/**
* render text at point
*/
public RenderText(
target: SVGElement|undefined,
text: string,
align: 'center' | 'left' | 'right',
point: Point,
classes?: string | string[]): void {
const node = SVGNode('text', {x: point.x, y: point.y, class: classes}, text);
switch (align) {
case 'right':
node.style.textAnchor = 'end';
break;
case 'center':
node.style.textAnchor = 'middle';
break;
default:
node.style.textAnchor = 'start';
break;
}
(target||this.group).appendChild(node);
}
/**
* render a donut, given a list of slices (as %)
* @param values
*/
public RenderDonut(
slices: DonutSlice[],
center: Point,
outer_radius: number,
inner_radius: number,
bounds_area: Area,
callouts: boolean,
classes?: string | string[]): void {
let start_angle = -Math.PI / 2; // start at 12:00
let end_angle = 0;
if (callouts) {
outer_radius *= .8;
inner_radius *= .7;
}
const PointOnCircle = (center: Point, radius: number, angle: number) => {
return [
Math.cos(angle) * radius + center.x,
Math.sin(angle) * radius + center.y,
];
};
for (const slice of slices) {
const title = slice.title || '';
const value = slice.percent;
const index = slice.index;
let d: string[] = [];
let half_angle = 0;
const outer = PointOnCircle.bind(0, center, outer_radius);
const inner = PointOnCircle.bind(0, center, inner_radius);
if (value > 0.5) {
// split into two segments
half_angle = start_angle + (value / 2) * Math.PI * 2;
end_angle = start_angle + value * Math.PI * 2;
const delta1 = half_angle - start_angle;
const delta2 = end_angle - half_angle;
d.push(
`M${outer(start_angle)}`,
`A${outer_radius},${outer_radius},${delta1},0,1,${outer(half_angle)}`,
`A${outer_radius},${outer_radius},${delta2},0,1,${outer(end_angle)}`,
`L${inner(end_angle)}`,
`A${inner_radius},${inner_radius},${delta2},0,0,${inner(half_angle)}`,
`A${inner_radius},${inner_radius},${delta1},0,0,${inner(start_angle)}`,
'Z');
}
else {
end_angle = start_angle + value * Math.PI * 2;
half_angle = (end_angle - start_angle) / 2 + start_angle;
const delta = end_angle - start_angle;
d.push(
`M${outer(start_angle)}`,
`A${outer_radius},${outer_radius},${delta},0,1,${outer(end_angle)}`,
`L${inner(end_angle)}`,
`A${inner_radius},${inner_radius},${delta},0,0,${inner(start_angle)}`,
'Z');
}
const node = SVGNode('path', {
d, class: (typeof index === 'undefined' ? undefined : `series-${index}`)
});
if (typeof index !== 'undefined') {
node.setAttribute('data-index', index.toString());
}
/*
if (title) {
node.addEventListener('mouseenter', (event) => {
this.parent.setAttribute('title', title);
});
node.addEventListener('mouseleave', (event) => {
this.parent.setAttribute('title', '');
});
}
*/
// we're creating a containing group so that we can nth-child the slices,
// otherwise they'll be in the same group as the title
const donut = SVGNode('g', {class: classes});
donut.appendChild(node);
this.group.appendChild(donut);
if (/*callouts &&*/ value >= .05 && title) {
const length = outer_radius - inner_radius;
d = [];
const anchor = PointOnCircle(center,
inner_radius + (outer_radius - inner_radius) / 2 + length, half_angle);
d.push(`M${PointOnCircle(center, inner_radius + (outer_radius - inner_radius) / 2, half_angle)}`);
d.push(`L${anchor}`);
donut.appendChild(SVGNode('path', { d, class: 'callout' }));
const text_parts: string[] = [];
const callout_label = SVGNode('text', {class: 'callout-label'});
donut.appendChild(callout_label);
const corrected = half_angle + Math.PI / 2;
const text = title;
callout_label.textContent = text;
let bounds = callout_label.getBoundingClientRect();
const metrics = {
width: bounds.width,
height: bounds.height,
};
// const metrics = this.MeasureText(text, ['donut', 'callout-label']);
let [x, y] = anchor;
x += metrics.height / 2 * Math.cos(half_angle);
y += metrics.height / 4 + metrics.height / 2 * Math.sin(half_angle);
let try_break = false;
if (corrected > Math.PI) {
if (x - metrics.width <= bounds_area.left) {
try_break = true;
}
}
else {
if (x + metrics.width > bounds_area.right) {
try_break = true;
}
}
// this breaks numbers, bad!
// const break_regex = /[\s-\W]/;
const break_regex = /[\s-]/;
if (try_break && break_regex.test(text)) {
let break_index = -1;
let break_value = 1;
// const indices: number[] = [];
for (let i = 0; i < text.length; i++) {
if (break_regex.test(text[i])) {
const index_value = Math.abs(0.5 - (i / text.length));
if (index_value < break_value) {
break_value = index_value;
break_index = i;
}
}
}
if (break_index > 0) {
text_parts.push(text.substr(0, break_index + 1).trim());
text_parts.push(text.substr(break_index + 1).trim());
}
}
else {
// ... ellipsis?
}
/*
if (y <= bounds_area.top) {
console.info("break top", title, y);
}
if (y >= bounds_area.bottom) {
console.info("break bottom", title, y);
}
*/
if (text_parts.length) {
let dy = 0;
let widest = 0;
const parts = text_parts.map((part) => {
callout_label.textContent = part;
bounds = callout_label.getBoundingClientRect();
const m = {
width: bounds.width,
height: bounds.height,
};
//const m = this.MeasureText(part, ['donut', 'callout-label']);
widest = Math.max(widest, m.width);
return { text: part, metrics: m };
});
// console.info('p', parts);
callout_label.textContent = '';
for (const part of parts) {
const tspan = SVGNode('tspan');
tspan.textContent = part.text;
const part_x = (corrected > Math.PI) ?
(x - (widest - part.metrics.width) / 2) :
(x + (widest - part.metrics.width) / 2);
tspan.setAttribute('x', part_x.toString());
tspan.setAttribute('dy', dy.toString());
callout_label.appendChild(tspan);
dy = part.metrics.height;
}
}
else {
// already in from measurement
// callout_label.textContent = title;
}
const text_anchor = corrected > Math.PI ? 'end' : 'start';
callout_label.setAttribute('text-anchor', text_anchor);
callout_label.setAttribute('x', x.toString());
callout_label.setAttribute('y', y.toString());
if (typeof index !== 'undefined') {
callout_label.setAttribute('data-index', index.toString());
}
}
start_angle = end_angle;
}
}
/*
protected PointOnCircle(angle: number, center: Point, radius: number) {
return [
Math.cos(angle) * radius + center.x,
Math.sin(angle) * radius + center.y,
];
}
*/
}