@dou-ui/chart 0.0.0-stage → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/chart.js +6 -0
- package/dist/chart.js.map +1 -0
- package/dist/chart.vue_vue_type_script_setup_true_lang.js +4245 -0
- package/dist/chart.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/area.js +6 -0
- package/dist/components/area.js.map +1 -0
- package/dist/components/area.vue_vue_type_script_setup_true_lang.js +55 -0
- package/dist/components/area.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/axis.js +6 -0
- package/dist/components/axis.js.map +1 -0
- package/dist/components/axis.vue_vue_type_script_setup_true_lang.js +46 -0
- package/dist/components/axis.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/bar.js +6 -0
- package/dist/components/bar.js.map +1 -0
- package/dist/components/bar.vue_vue_type_script_setup_true_lang.js +46 -0
- package/dist/components/bar.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/boxplot.js +6 -0
- package/dist/components/boxplot.js.map +1 -0
- package/dist/components/boxplot.vue_vue_type_script_setup_true_lang.js +42 -0
- package/dist/components/boxplot.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/brush.js +6 -0
- package/dist/components/brush.js.map +1 -0
- package/dist/components/brush.vue_vue_type_script_setup_true_lang.js +42 -0
- package/dist/components/brush.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/funnel.js +6 -0
- package/dist/components/funnel.js.map +1 -0
- package/dist/components/funnel.vue_vue_type_script_setup_true_lang.js +41 -0
- package/dist/components/funnel.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/gauge.js +6 -0
- package/dist/components/gauge.js.map +1 -0
- package/dist/components/gauge.vue_vue_type_script_setup_true_lang.js +91 -0
- package/dist/components/gauge.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/heatmap.js +6 -0
- package/dist/components/heatmap.js.map +1 -0
- package/dist/components/heatmap.vue_vue_type_script_setup_true_lang.js +45 -0
- package/dist/components/heatmap.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/histogram.js +6 -0
- package/dist/components/histogram.js.map +1 -0
- package/dist/components/histogram.vue_vue_type_script_setup_true_lang.js +61 -0
- package/dist/components/histogram.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/line.js +6 -0
- package/dist/components/line.js.map +1 -0
- package/dist/components/line.vue_vue_type_script_setup_true_lang.js +52 -0
- package/dist/components/line.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/marker.js +6 -0
- package/dist/components/marker.js.map +1 -0
- package/dist/components/marker.vue_vue_type_script_setup_true_lang.js +31 -0
- package/dist/components/marker.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/pie.js +6 -0
- package/dist/components/pie.js.map +1 -0
- package/dist/components/pie.vue_vue_type_script_setup_true_lang.js +42 -0
- package/dist/components/pie.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/radar.js +6 -0
- package/dist/components/radar.js.map +1 -0
- package/dist/components/radar.vue_vue_type_script_setup_true_lang.js +45 -0
- package/dist/components/radar.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/range.js +6 -0
- package/dist/components/range.js.map +1 -0
- package/dist/components/range.vue_vue_type_script_setup_true_lang.js +27 -0
- package/dist/components/range.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/scatter.js +6 -0
- package/dist/components/scatter.js.map +1 -0
- package/dist/components/scatter.vue_vue_type_script_setup_true_lang.js +45 -0
- package/dist/components/scatter.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/sunburst.js +6 -0
- package/dist/components/sunburst.js.map +1 -0
- package/dist/components/sunburst.vue_vue_type_script_setup_true_lang.js +36 -0
- package/dist/components/sunburst.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/tooltip.js +6 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/components/tooltip.vue_vue_type_script_setup_true_lang.js +31 -0
- package/dist/components/tooltip.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/treemap.js +6 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/treemap.vue_vue_type_script_setup_true_lang.js +33 -0
- package/dist/components/treemap.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/waterfall.js +6 -0
- package/dist/components/waterfall.js.map +1 -0
- package/dist/components/waterfall.vue_vue_type_script_setup_true_lang.js +49 -0
- package/dist/components/waterfall.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/zoom.js +6 -0
- package/dist/components/zoom.js.map +1 -0
- package/dist/components/zoom.vue_vue_type_script_setup_true_lang.js +37 -0
- package/dist/components/zoom.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/context.js +30 -0
- package/dist/context.js.map +1 -0
- package/dist/core/bar.js +223 -0
- package/dist/core/bar.js.map +1 -0
- package/dist/core/box.js +150 -0
- package/dist/core/box.js.map +1 -0
- package/dist/core/colors.js +58 -0
- package/dist/core/colors.js.map +1 -0
- package/dist/core/data.js +257 -0
- package/dist/core/data.js.map +1 -0
- package/dist/core/decimate.js +211 -0
- package/dist/core/decimate.js.map +1 -0
- package/dist/core/format.js +151 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/funnel.js +68 -0
- package/dist/core/funnel.js.map +1 -0
- package/dist/core/grid.js +87 -0
- package/dist/core/grid.js.map +1 -0
- package/dist/core/heatmap.js +187 -0
- package/dist/core/heatmap.js.map +1 -0
- package/dist/core/histogram.js +81 -0
- package/dist/core/histogram.js.map +1 -0
- package/dist/core/hit.js +115 -0
- package/dist/core/hit.js.map +1 -0
- package/dist/core/labels.js +141 -0
- package/dist/core/labels.js.map +1 -0
- package/dist/core/layout.js +1002 -0
- package/dist/core/layout.js.map +1 -0
- package/dist/core/line.js +230 -0
- package/dist/core/line.js.map +1 -0
- package/dist/core/marker.js +205 -0
- package/dist/core/marker.js.map +1 -0
- package/dist/core/model.js +391 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/painter.js +79 -0
- package/dist/core/painter.js.map +1 -0
- package/dist/core/pattern.js +102 -0
- package/dist/core/pattern.js.map +1 -0
- package/dist/core/pie.js +119 -0
- package/dist/core/pie.js.map +1 -0
- package/dist/core/radar.js +83 -0
- package/dist/core/radar.js.map +1 -0
- package/dist/core/scale.js +149 -0
- package/dist/core/scale.js.map +1 -0
- package/dist/core/scatter.js +263 -0
- package/dist/core/scatter.js.map +1 -0
- package/dist/core/svg.js +209 -0
- package/dist/core/svg.js.map +1 -0
- package/dist/core/sync.js +21 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/time.js +217 -0
- package/dist/core/time.js.map +1 -0
- package/dist/core/tree.js +348 -0
- package/dist/core/tree.js.map +1 -0
- package/dist/core/warn.js +11 -0
- package/dist/core/warn.js.map +1 -0
- package/dist/core/waterfall.js +134 -0
- package/dist/core/waterfall.js.map +1 -0
- package/dist/core/window.js +94 -0
- package/dist/core/window.js.map +1 -0
- package/dist/define.js +62 -0
- package/dist/define.js.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/locale.js +170 -0
- package/dist/locale.js.map +1 -0
- package/dist/overlay.js +6 -0
- package/dist/overlay.js.map +1 -0
- package/dist/overlay.vue_vue_type_script_setup_true_lang.js +207 -0
- package/dist/overlay.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/pointer.js +6 -0
- package/dist/pointer.js.map +1 -0
- package/dist/pointer.vue_vue_type_script_setup_true_lang.js +136 -0
- package/dist/pointer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/static.js +6 -0
- package/dist/static.js.map +1 -0
- package/dist/static.vue_vue_type_script_setup_true_lang.js +34 -0
- package/dist/static.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/style.css +1 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +3 -0
- package/dist/table.js +6 -0
- package/dist/table.js.map +1 -0
- package/dist/table.vue_vue_type_script_setup_true_lang.js +52 -0
- package/dist/table.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/types/chart.vue.d.ts +24 -0
- package/dist/types/components/area.vue.d.ts +16 -0
- package/dist/types/components/axis.vue.d.ts +8 -0
- package/dist/types/components/bar.vue.d.ts +14 -0
- package/dist/types/components/boxplot.vue.d.ts +14 -0
- package/dist/types/components/brush.vue.d.ts +22 -0
- package/dist/types/components/funnel.vue.d.ts +14 -0
- package/dist/types/components/gauge.vue.d.ts +17 -0
- package/dist/types/components/heatmap.vue.d.ts +14 -0
- package/dist/types/components/histogram.vue.d.ts +14 -0
- package/dist/types/components/line.vue.d.ts +16 -0
- package/dist/types/components/marker.vue.d.ts +6 -0
- package/dist/types/components/pie.vue.d.ts +23 -0
- package/dist/types/components/radar.vue.d.ts +16 -0
- package/dist/types/components/range.vue.d.ts +12 -0
- package/dist/types/components/scatter.vue.d.ts +14 -0
- package/dist/types/components/sunburst.vue.d.ts +17 -0
- package/dist/types/components/tooltip.vue.d.ts +13 -0
- package/dist/types/components/treemap.vue.d.ts +8 -0
- package/dist/types/components/waterfall.vue.d.ts +16 -0
- package/dist/types/components/zoom.vue.d.ts +18 -0
- package/dist/types/context.d.ts +87 -0
- package/dist/types/core/bar.d.ts +38 -0
- package/dist/types/core/box.d.ts +25 -0
- package/dist/types/core/colors.d.ts +16 -0
- package/dist/types/core/data.d.ts +72 -0
- package/dist/types/core/decimate.d.ts +39 -0
- package/dist/types/core/format.d.ts +33 -0
- package/dist/types/core/funnel.d.ts +15 -0
- package/dist/types/core/grid.d.ts +18 -0
- package/dist/types/core/heatmap.d.ts +17 -0
- package/dist/types/core/histogram.d.ts +23 -0
- package/dist/types/core/hit.d.ts +31 -0
- package/dist/types/core/labels.d.ts +45 -0
- package/dist/types/core/layout.d.ts +259 -0
- package/dist/types/core/line.d.ts +36 -0
- package/dist/types/core/marker.d.ts +67 -0
- package/dist/types/core/model.d.ts +86 -0
- package/dist/types/core/painter.d.ts +34 -0
- package/dist/types/core/pattern.d.ts +14 -0
- package/dist/types/core/pie.d.ts +66 -0
- package/dist/types/core/radar.d.ts +12 -0
- package/dist/types/core/scale.d.ts +60 -0
- package/dist/types/core/scatter.d.ts +16 -0
- package/dist/types/core/series.d.ts +228 -0
- package/dist/types/core/svg.d.ts +63 -0
- package/dist/types/core/sync.d.ts +8 -0
- package/dist/types/core/time.d.ts +20 -0
- package/dist/types/core/tree.d.ts +84 -0
- package/dist/types/core/warn.d.ts +4 -0
- package/dist/types/core/waterfall.d.ts +24 -0
- package/dist/types/core/window.d.ts +36 -0
- package/dist/types/define.d.ts +115 -0
- package/dist/types/index.d.ts +363 -0
- package/dist/types/locale.d.ts +100 -0
- package/dist/types/overlay.vue.d.ts +95 -0
- package/dist/types/pointer.vue.d.ts +53 -0
- package/dist/types/static.vue.d.ts +24 -0
- package/dist/types/table.vue.d.ts +19 -0
- package/dist/types/types.d.ts +555 -0
- package/dist/types/zoom-layer.vue.d.ts +41 -0
- package/dist/zoom-layer.js +6 -0
- package/dist/zoom-layer.js.map +1 -0
- package/dist/zoom-layer.vue_vue_type_script_setup_true_lang.js +210 -0
- package/dist/zoom-layer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/package.json +53 -4
|
@@ -0,0 +1,4245 @@
|
|
|
1
|
+
import { crisp } from "./core/painter.js";
|
|
2
|
+
import { CHART_PATTERNS, patternCss } from "./core/pattern.js";
|
|
3
|
+
import { baseline, categoryScale, linearScale, logScale } from "./core/scale.js";
|
|
4
|
+
import { chartKey } from "./context.js";
|
|
5
|
+
import { barPlacement } from "./core/bar.js";
|
|
6
|
+
import { allX, angleAt, nearestPoint, nearestX, neighborGap, rowAt, sortedOrder } from "./core/hit.js";
|
|
7
|
+
import { extent, read, rowOf } from "./core/data.js";
|
|
8
|
+
import { heatColor, heatRange, heatShare } from "./core/heatmap.js";
|
|
9
|
+
import { nearestSpoke, spokeAngle } from "./core/radar.js";
|
|
10
|
+
import { FUNNEL_BAR } from "./core/funnel.js";
|
|
11
|
+
import { isTotal } from "./core/waterfall.js";
|
|
12
|
+
import { warnOnce } from "./core/warn.js";
|
|
13
|
+
import { boxes } from "./core/box.js";
|
|
14
|
+
import { numberFormatFull, numberTickFormat, timeFormatFull, timePrecision } from "./core/format.js";
|
|
15
|
+
import { createColorResolver, observeTheme } from "./core/colors.js";
|
|
16
|
+
import { joinGroup, tellGroup } from "./core/sync.js";
|
|
17
|
+
import { clampWindow, intervalToWindow, panWindow, snapCategories, unitsOf, valueToNumber, windowToInterval, within, zoomWindow } from "./core/window.js";
|
|
18
|
+
import { arcBox, layoutChart, layoutFunnel, layoutPie, layoutRadar } from "./core/layout.js";
|
|
19
|
+
import { buildModel, seriesName, smallestGap } from "./core/model.js";
|
|
20
|
+
import { drawMarkers, placeMarkers } from "./core/marker.js";
|
|
21
|
+
import { contrast, luminance, placeDataLabels, spread } from "./core/labels.js";
|
|
22
|
+
import { FADED, drawPie, pieSlices, sliceOuter } from "./core/pie.js";
|
|
23
|
+
import { SUNBURST_FADE, buildTree, drawSunburst, drawTreemap, hasHeader, layoutSunburst, layoutTreemap, ringTreemap, sunburstAt, sunburstBranch, treePath, treemapAt } from "./core/tree.js";
|
|
24
|
+
import { SvgPainter, escapeXml, isSvg, num, paintAttribute } from "./core/svg.js";
|
|
25
|
+
import { format, useChartMessages } from "./locale.js";
|
|
26
|
+
import overlay_default from "./overlay.js";
|
|
27
|
+
import pointer_default from "./pointer.js";
|
|
28
|
+
import table_default from "./table.js";
|
|
29
|
+
import static_default from "./static.js";
|
|
30
|
+
import zoom_layer_default from "./zoom-layer.js";
|
|
31
|
+
import { holdPointer, svgToPNG, useElementSize, useLocale, useNamespace, useReducedMotion } from "dou-ui";
|
|
32
|
+
import { computed, createElementBlock, createElementVNode, createVNode, defineComponent, getCurrentInstance, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, provide, ref, renderSlot, shallowRef, toRaw, unref, useId, useSlots, useTemplateRef, watch, watchEffect } from "vue";
|
|
33
|
+
// src/chart.vue?vue&type=script&setup=true&lang.ts
|
|
34
|
+
var _hoisted_1 = [
|
|
35
|
+
"role",
|
|
36
|
+
"aria-roledescription",
|
|
37
|
+
"aria-label",
|
|
38
|
+
"aria-busy",
|
|
39
|
+
"tabindex"
|
|
40
|
+
];
|
|
41
|
+
var LABEL_ROOM = .12;
|
|
42
|
+
/**
|
|
43
|
+
* The model with room on each value axis for data labels past the bars'
|
|
44
|
+
* ends and above the points, so the highest one isn't left out.
|
|
45
|
+
*/
|
|
46
|
+
var TREE_EDGE = 4;
|
|
47
|
+
var SLICE_COLORS = 7;
|
|
48
|
+
var LEGEND_SIDE_LEAST = 480;
|
|
49
|
+
/** The text of a data label, or `null` for none. */
|
|
50
|
+
var PIE_LABEL_SHARE = .26;
|
|
51
|
+
/**
|
|
52
|
+
* The smallest a pie gets to keep its labels outside: a share of its size
|
|
53
|
+
* without them, or a radius in px that still reads. Smaller, they go inside.
|
|
54
|
+
*/
|
|
55
|
+
var PIE_LEAST = .6;
|
|
56
|
+
var PIE_LEAST_RADIUS = 48;
|
|
57
|
+
/** The pie's labels, and the lines out to those outside it. */
|
|
58
|
+
var EDGE_ROOM = 4;
|
|
59
|
+
var NEAR_LINE = 8;
|
|
60
|
+
/**
|
|
61
|
+
* The series under the pointer, brought forward while the tooltip shows
|
|
62
|
+
* all of them: the line it is near, or the bar or area it is over. A
|
|
63
|
+
* point picked on its own (a scatter's) brings its series forward too.
|
|
64
|
+
* `null` with a single series, or none under the pointer.
|
|
65
|
+
*/
|
|
66
|
+
var HOLD = 400;
|
|
67
|
+
var WHEEL_ZOOM = .01;
|
|
68
|
+
var WHEEL_MOST = 40;
|
|
69
|
+
var STEADY = 300;
|
|
70
|
+
var MOTION_LIMIT = 2e3;
|
|
71
|
+
var ENTER = 320;
|
|
72
|
+
var UPDATE = 220;
|
|
73
|
+
/** A move under way: from what was shown to the current data. */
|
|
74
|
+
var TABLE_ROWS = 1e3;
|
|
75
|
+
var BACKDROP = 4;
|
|
76
|
+
/** A line of text whose 16px box starts at `top`, centered on its cap height as in the page. */
|
|
77
|
+
var chart_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
|
|
78
|
+
name: "DouChart",
|
|
79
|
+
__name: "chart",
|
|
80
|
+
props: {
|
|
81
|
+
data: { default: () => [] },
|
|
82
|
+
x: {},
|
|
83
|
+
height: { default: 320 },
|
|
84
|
+
ariaLabel: {},
|
|
85
|
+
legend: {
|
|
86
|
+
type: Boolean,
|
|
87
|
+
default: void 0
|
|
88
|
+
},
|
|
89
|
+
legendPlacement: {},
|
|
90
|
+
palette: {},
|
|
91
|
+
patterns: { type: Boolean },
|
|
92
|
+
horizontal: { type: Boolean },
|
|
93
|
+
table: { type: Boolean },
|
|
94
|
+
messages: {},
|
|
95
|
+
loading: { type: Boolean },
|
|
96
|
+
ssrWidth: { default: 640 }
|
|
97
|
+
},
|
|
98
|
+
emits: [
|
|
99
|
+
"select",
|
|
100
|
+
"hover",
|
|
101
|
+
"update:table"
|
|
102
|
+
],
|
|
103
|
+
setup(__props, { expose: __expose, emit: __emit }) {
|
|
104
|
+
const props = __props;
|
|
105
|
+
const slots = useSlots();
|
|
106
|
+
const emit = __emit;
|
|
107
|
+
const ns = useNamespace("chart");
|
|
108
|
+
const { locale } = useLocale();
|
|
109
|
+
const messages = useChartMessages(() => props.messages);
|
|
110
|
+
const rootRef = useTemplateRef("root");
|
|
111
|
+
const canvasRef = useTemplateRef("canvas");
|
|
112
|
+
const instance = getCurrentInstance();
|
|
113
|
+
/**
|
|
114
|
+
* Drawn on the server, or taking over a page drawn there: until the canvas
|
|
115
|
+
* has drawn, the chart is an SVG at `ssrWidth` (the server can't know the width).
|
|
116
|
+
*/
|
|
117
|
+
const fromServer = ref(typeof window === "undefined" || !!instance?.vnode.el);
|
|
118
|
+
const size = useElementSize(rootRef, fromServer.value ? { initial: {
|
|
119
|
+
width: props.ssrWidth,
|
|
120
|
+
height: typeof props.height === "number" ? props.height : 320
|
|
121
|
+
} } : {});
|
|
122
|
+
const seriesParts = shallowRef([]);
|
|
123
|
+
const axisParts = shallowRef([]);
|
|
124
|
+
const pieParts = shallowRef([]);
|
|
125
|
+
const tooltipParts = shallowRef([]);
|
|
126
|
+
const zoomParts = shallowRef([]);
|
|
127
|
+
const brushParts = shallowRef([]);
|
|
128
|
+
const rangeParts = shallowRef([]);
|
|
129
|
+
const markerParts = shallowRef([]);
|
|
130
|
+
const treeParts = shallowRef([]);
|
|
131
|
+
function inPageOrder(parts) {
|
|
132
|
+
return [...parts].sort((a, b) => {
|
|
133
|
+
const [x, y] = [a.el(), b.el()];
|
|
134
|
+
if (!x || !y) return 0;
|
|
135
|
+
return x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function adder(parts) {
|
|
139
|
+
return (part) => {
|
|
140
|
+
parts.value = inPageOrder([...parts.value, part]);
|
|
141
|
+
return () => parts.value = parts.value.filter((p) => p !== part);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
provide(chartKey, {
|
|
145
|
+
addSeries: adder(seriesParts),
|
|
146
|
+
addAxis: adder(axisParts),
|
|
147
|
+
addPie: adder(pieParts),
|
|
148
|
+
addTooltip: adder(tooltipParts),
|
|
149
|
+
addZoom: adder(zoomParts),
|
|
150
|
+
addBrush: adder(brushParts),
|
|
151
|
+
addRange: adder(rangeParts),
|
|
152
|
+
addMarker: adder(markerParts),
|
|
153
|
+
addTree: adder(treeParts),
|
|
154
|
+
data: () => props.data,
|
|
155
|
+
locale: () => locale.value.name,
|
|
156
|
+
messages: () => messages.value
|
|
157
|
+
});
|
|
158
|
+
const themeVersion = ref(0);
|
|
159
|
+
const resolveColor = createColorResolver(() => rootRef.value);
|
|
160
|
+
function readTheme() {
|
|
161
|
+
resolveColor.clear();
|
|
162
|
+
measured.clear();
|
|
163
|
+
themeVersion.value++;
|
|
164
|
+
}
|
|
165
|
+
let stopTheme;
|
|
166
|
+
/** A touch screen: the legend's rows go further apart, for a finger. */
|
|
167
|
+
const coarse = ref(false);
|
|
168
|
+
const coarseQuery = typeof window === "undefined" || !window.matchMedia ? void 0 : window.matchMedia("(pointer: coarse)");
|
|
169
|
+
const readCoarse = () => coarse.value = !!coarseQuery?.matches;
|
|
170
|
+
onMounted(() => {
|
|
171
|
+
readCoarse();
|
|
172
|
+
coarseQuery?.addEventListener?.("change", readCoarse);
|
|
173
|
+
});
|
|
174
|
+
onBeforeUnmount(() => coarseQuery?.removeEventListener?.("change", readCoarse));
|
|
175
|
+
const fonts = typeof document === "undefined" ? void 0 : document.fonts;
|
|
176
|
+
onMounted(() => {
|
|
177
|
+
readTheme();
|
|
178
|
+
stopTheme = observeTheme(readTheme);
|
|
179
|
+
fonts?.addEventListener?.("loadingdone", readTheme);
|
|
180
|
+
});
|
|
181
|
+
onBeforeUnmount(() => {
|
|
182
|
+
stopTheme?.();
|
|
183
|
+
fonts?.removeEventListener?.("loadingdone", readTheme);
|
|
184
|
+
});
|
|
185
|
+
const colors = computed(() => {
|
|
186
|
+
themeVersion.value;
|
|
187
|
+
return {
|
|
188
|
+
surface: resolveColor("var(--dou-chart-surface)"),
|
|
189
|
+
grid: resolveColor("var(--dou-chart-grid)"),
|
|
190
|
+
baseline: resolveColor("var(--dou-chart-baseline)")
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
const measured = /* @__PURE__ */ new Map();
|
|
194
|
+
let measurer = null;
|
|
195
|
+
function measure(text) {
|
|
196
|
+
let width = measured.get(text);
|
|
197
|
+
if (width !== void 0) return width;
|
|
198
|
+
const root = rootRef.value;
|
|
199
|
+
if (root && typeof document !== "undefined") {
|
|
200
|
+
if (!measurer || measurer.parentNode !== root) {
|
|
201
|
+
measurer = document.createElement("span");
|
|
202
|
+
measurer.className = `${ns.e("tick")} ${ns.e("measure")}`;
|
|
203
|
+
measurer.setAttribute("aria-hidden", "true");
|
|
204
|
+
measurer.appendChild(document.createElement("span")).className = ns.e("text");
|
|
205
|
+
root.appendChild(measurer);
|
|
206
|
+
}
|
|
207
|
+
measurer.firstChild.textContent = text;
|
|
208
|
+
width = measurer.getBoundingClientRect().width;
|
|
209
|
+
}
|
|
210
|
+
if (!width) {
|
|
211
|
+
width = 0;
|
|
212
|
+
for (const char of text) width += char.charCodeAt(0) > 11904 ? 12 : 7;
|
|
213
|
+
}
|
|
214
|
+
measured.set(text, width);
|
|
215
|
+
return width;
|
|
216
|
+
}
|
|
217
|
+
const declaredAxes = computed(() => {
|
|
218
|
+
const declared = {};
|
|
219
|
+
for (const part of axisParts.value) declared[part.props.for] = part.props;
|
|
220
|
+
return declared;
|
|
221
|
+
});
|
|
222
|
+
const defaultAxis = computed(() => declaredAxes.value.y2 && !declaredAxes.value.y ? "y2" : "y");
|
|
223
|
+
const uses = (axis) => seriesParts.value.some((part) => (part.props.axis ?? defaultAxis.value) === axis);
|
|
224
|
+
/** Whether a value axis lists categories (a heatmap's rows): no grid lines across it by default. */
|
|
225
|
+
const listsCategories = (axis) => seriesParts.value.some((part) => part.kind.categoryY && (part.props.axis ?? defaultAxis.value) === axis);
|
|
226
|
+
/**
|
|
227
|
+
* The axes by what they measure, and where each is drawn: x at the bottom,
|
|
228
|
+
* y on the left and y2 on the right; with `horizontal`, x on the left,
|
|
229
|
+
* y at the bottom and y2 at the top.
|
|
230
|
+
*/
|
|
231
|
+
const axes = computed(() => {
|
|
232
|
+
const declared = declaredAxes.value;
|
|
233
|
+
const places = props.horizontal ? {
|
|
234
|
+
x: "left",
|
|
235
|
+
y: "bottom",
|
|
236
|
+
y2: "top"
|
|
237
|
+
} : {
|
|
238
|
+
x: "bottom",
|
|
239
|
+
y: "left",
|
|
240
|
+
y2: "right"
|
|
241
|
+
};
|
|
242
|
+
const config = (role, grid) => {
|
|
243
|
+
const { for: _, percent: share, ...given } = declared[role] ?? {};
|
|
244
|
+
const position = places[role];
|
|
245
|
+
const out = {
|
|
246
|
+
...given,
|
|
247
|
+
position,
|
|
248
|
+
grid: given.grid ?? grid,
|
|
249
|
+
hidden: given.hidden ?? false
|
|
250
|
+
};
|
|
251
|
+
if (share && role !== "x") {
|
|
252
|
+
out.format ??= percentTicks.value.format;
|
|
253
|
+
out.max ??= 1;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
const y = declared.y || uses("y") || !uses("y2");
|
|
258
|
+
return {
|
|
259
|
+
x: config("x", false),
|
|
260
|
+
y: y ? config("y", !listsCategories("y")) : null,
|
|
261
|
+
y2: declared.y2 || uses("y2") ? config("y2", !y && !listsCategories("y2")) : null
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
const model = computed(() => {
|
|
265
|
+
const inputs = seriesParts.value.map((part, index) => ({
|
|
266
|
+
index,
|
|
267
|
+
kind: part.kind,
|
|
268
|
+
props: part.props,
|
|
269
|
+
hidden: part.hidden.value
|
|
270
|
+
}));
|
|
271
|
+
const type = axes.value.x.type;
|
|
272
|
+
return labelRoom(reachMarkers(buildModel(inputs, {
|
|
273
|
+
data: toRaw(props.data),
|
|
274
|
+
x: props.x,
|
|
275
|
+
xKind: type === "log" ? "linear" : type,
|
|
276
|
+
axis: defaultAxis.value,
|
|
277
|
+
percent: {
|
|
278
|
+
y: !!declaredAxes.value.y?.percent,
|
|
279
|
+
y2: !!declaredAxes.value.y2?.percent
|
|
280
|
+
},
|
|
281
|
+
sort: declaredAxes.value.x?.sort
|
|
282
|
+
})));
|
|
283
|
+
});
|
|
284
|
+
if (process.env.NODE_ENV !== "production") watchEffect(() => {
|
|
285
|
+
for (const axis of ["y", "y2"]) if (declaredAxes.value[axis]?.sort !== void 0) warnOnce(`sort-${axis}`, `<DouChartAxis for="${axis}">: \`sort\` orders a category x axis only, so it is left out here.`);
|
|
286
|
+
});
|
|
287
|
+
/** Percentages on the ticks of a `percent` axis: whole ones. */
|
|
288
|
+
const percentTicks = computed(() => new Intl.NumberFormat(locale.value.name, {
|
|
289
|
+
style: "percent",
|
|
290
|
+
maximumFractionDigits: 0
|
|
291
|
+
}));
|
|
292
|
+
/** Room past the data on each value axis for labels outside bars and above points: a part of its span. */
|
|
293
|
+
function labelRoom(m) {
|
|
294
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
295
|
+
for (const s of m.series) {
|
|
296
|
+
const p = s.input.props;
|
|
297
|
+
if (!p.labels || !s.input.kind.labels || p.labelPosition === "end") continue;
|
|
298
|
+
if (s.input.kind.categoryY) continue;
|
|
299
|
+
if (p.labelPosition === "inside" || s.stackEnds && s.input.kind.band && !p.labelPosition) continue;
|
|
300
|
+
wanted.add(s.axis);
|
|
301
|
+
}
|
|
302
|
+
if (!wanted.size) return m;
|
|
303
|
+
const y = { ...m.y };
|
|
304
|
+
for (const axis of wanted) {
|
|
305
|
+
const known = y[axis];
|
|
306
|
+
if (!known) continue;
|
|
307
|
+
const zero = declaredAxes.value[axis]?.zero ?? m.zero[axis];
|
|
308
|
+
const lo = zero ? Math.min(known[0], 0) : known[0];
|
|
309
|
+
const hi = zero ? Math.max(known[1], 0) : known[1];
|
|
310
|
+
const span = hi - lo || Math.abs(hi) || 1;
|
|
311
|
+
y[axis] = [lo < 0 ? known[0] - span * LABEL_ROOM : known[0], hi > 0 ? known[1] + span * LABEL_ROOM : known[1]];
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
...m,
|
|
315
|
+
y
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** The model with each value axis reaching the markers' values on it, so a target shows. */
|
|
319
|
+
function reachMarkers(m) {
|
|
320
|
+
let y = null;
|
|
321
|
+
for (const part of markerParts.value) {
|
|
322
|
+
const value = part.props.y;
|
|
323
|
+
if (value === void 0) continue;
|
|
324
|
+
const axis = part.props.axis ?? defaultAxis.value;
|
|
325
|
+
const known = (y ?? m.y)[axis];
|
|
326
|
+
if (!known) continue;
|
|
327
|
+
let [lo, hi] = known;
|
|
328
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
329
|
+
if (!Number.isFinite(v)) continue;
|
|
330
|
+
lo = Math.min(lo, v);
|
|
331
|
+
hi = Math.max(hi, v);
|
|
332
|
+
}
|
|
333
|
+
if (part.props.label && !Array.isArray(value) && !Array.isArray(part.props.x) && value >= hi) hi = value + (value - lo) * .1;
|
|
334
|
+
if (lo !== known[0] || hi !== known[1]) {
|
|
335
|
+
y ??= { ...m.y };
|
|
336
|
+
y[axis] = [lo, hi];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return y ? {
|
|
340
|
+
...m,
|
|
341
|
+
y
|
|
342
|
+
} : m;
|
|
343
|
+
}
|
|
344
|
+
const pie = computed(() => pieParts.value[0] ?? null);
|
|
345
|
+
const slices = computed(() => {
|
|
346
|
+
const part = pie.value;
|
|
347
|
+
if (!part) return [];
|
|
348
|
+
const label = part.props.label ?? props.x;
|
|
349
|
+
return pieSlices(toRaw(part.props.data ?? props.data), part.props.value, label, part.props.color, part.hidden.value, {
|
|
350
|
+
start: pieArc.value[0],
|
|
351
|
+
end: pieArc.value[1],
|
|
352
|
+
rose: !!part.props.rose
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
/** The part of the turn the pie takes, in radians clockwise from 12 o'clock. */
|
|
356
|
+
const pieArc = computed(() => {
|
|
357
|
+
const p = pie.value?.props;
|
|
358
|
+
const start = (p?.startAngle ?? 0) * Math.PI / 180;
|
|
359
|
+
const end = p?.endAngle === void 0 ? start + Math.PI * 2 : p.endAngle * Math.PI / 180;
|
|
360
|
+
if (!(end > start)) warnOnce("pie-angles", `<${pie.value?.gauge ? "DouChartGauge" : "DouChartPie"}>: \`end-angle\` must be past \`start-angle\` (clockwise); nothing is drawn.`);
|
|
361
|
+
return [start, Math.min(end, start + Math.PI * 2)];
|
|
362
|
+
});
|
|
363
|
+
/** The slices that take room: not turned off. */
|
|
364
|
+
const shownSlices = computed(() => slices.value.filter((slice) => !slice.hidden));
|
|
365
|
+
if (process.env.NODE_ENV !== "production") watch([
|
|
366
|
+
pieParts,
|
|
367
|
+
seriesParts,
|
|
368
|
+
treeParts
|
|
369
|
+
], ([pies, series, trees]) => {
|
|
370
|
+
const pieName = pies[0]?.gauge ? "DouChartGauge" : "DouChartPie";
|
|
371
|
+
if (pies.length > 1 || pies.length && (series.length || trees.length)) console.warn(`[@dou-ui/chart] a <${pieName}> is drawn alone: other series, pies and trees are left out.`);
|
|
372
|
+
else if (trees.length > 1 || trees.length && series.length) {
|
|
373
|
+
const name = trees[0].shape === "treemap" ? "DouChartTreemap" : "DouChartSunburst";
|
|
374
|
+
console.warn(`[@dou-ui/chart] a <${name}> is drawn alone: other series and trees are left out.`);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
const tree = computed(() => !pie.value && treeParts.value[0] || null);
|
|
378
|
+
/** The tree read from the part's rows, its nodes parents first. */
|
|
379
|
+
const treeData = computed(() => {
|
|
380
|
+
const part = tree.value;
|
|
381
|
+
if (!part) return null;
|
|
382
|
+
const p = part.props;
|
|
383
|
+
const data = toRaw(p.data ?? props.data);
|
|
384
|
+
if (data && !Array.isArray(data)) warnOnce("tree-data", "a treemap or sunburst reads nested rows (an array); data by column is left out.");
|
|
385
|
+
return buildTree(Array.isArray(data) ? data : [], {
|
|
386
|
+
label: p.label ?? "name",
|
|
387
|
+
value: p.value ?? "value",
|
|
388
|
+
children: p.children ?? "children"
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
/** Room at the edges of a tree, in px. */
|
|
392
|
+
const treeLayout = computed(() => {
|
|
393
|
+
const width = size.width.value;
|
|
394
|
+
const height = size.height.value;
|
|
395
|
+
const part = tree.value;
|
|
396
|
+
const data = treeData.value;
|
|
397
|
+
if (!width || !height || !part || !data) return null;
|
|
398
|
+
themeVersion.value;
|
|
399
|
+
const plot = {
|
|
400
|
+
x: TREE_EDGE,
|
|
401
|
+
y: TREE_EDGE,
|
|
402
|
+
width: Math.max(0, width - 8),
|
|
403
|
+
height: Math.max(0, height - 8)
|
|
404
|
+
};
|
|
405
|
+
const cx = width / 2;
|
|
406
|
+
const cy = height / 2;
|
|
407
|
+
const radius = Math.max(0, Math.min(plot.width, plot.height) / 2);
|
|
408
|
+
const inner = radius * Math.min(.9, Math.max(0, part.props.innerRadius ?? .3));
|
|
409
|
+
if (part.shape === "treemap") layoutTreemap(data.root, plot);
|
|
410
|
+
else layoutSunburst(data.root, inner, radius);
|
|
411
|
+
return {
|
|
412
|
+
width,
|
|
413
|
+
height,
|
|
414
|
+
plot,
|
|
415
|
+
cx,
|
|
416
|
+
cy,
|
|
417
|
+
radius,
|
|
418
|
+
inner,
|
|
419
|
+
nodes: [...data.nodes],
|
|
420
|
+
legend: [],
|
|
421
|
+
legendBox: void 0
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
/**
|
|
425
|
+
* Each node's color: its own, else its parent's, the top level taking the
|
|
426
|
+
* palette's. A sunburst's rings pale outwards, towards the surface.
|
|
427
|
+
*/
|
|
428
|
+
const treeColors = computed(() => {
|
|
429
|
+
themeVersion.value;
|
|
430
|
+
const part = tree.value;
|
|
431
|
+
const data = treeData.value;
|
|
432
|
+
if (!part || !data) return [];
|
|
433
|
+
const palette = ownPalette.value;
|
|
434
|
+
const own = part.props.color;
|
|
435
|
+
const css = [];
|
|
436
|
+
for (const node of data.nodes) {
|
|
437
|
+
if (node.depth === 0) {
|
|
438
|
+
css.push("");
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const raw = !own || !node.row ? void 0 : typeof own === "function" ? own(node.row, node.depth) : read(node.row, own, node.place);
|
|
442
|
+
const given = typeof raw === "string" && raw ? raw : void 0;
|
|
443
|
+
const base = given ? toneOrColor(given) : node.depth > 1 ? css[node.parent.index] : palette ? toneOrColor(palette[node.group % palette.length]) : `var(--dou-color-chart-${node.group % SLICE_COLORS + 2})`;
|
|
444
|
+
css.push(base);
|
|
445
|
+
}
|
|
446
|
+
return data.nodes.map((node, k) => {
|
|
447
|
+
if (node.depth === 0) return "";
|
|
448
|
+
const shade = part.shape === "sunburst" && node.depth > 1;
|
|
449
|
+
const pale = Math.max(40, 100 - (node.depth - 1) * 24);
|
|
450
|
+
return resolveColor(shade ? `color-mix(in oklab, ${css[k]} ${pale}%, var(--dou-chart-surface))` : css[k]);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
/** The value as text, and its share of the whole. */
|
|
454
|
+
function treeValue(node) {
|
|
455
|
+
const root = treeData.value?.root;
|
|
456
|
+
return format(messages.value.share, {
|
|
457
|
+
value: numberFormatFull(locale.value.name)(node.value),
|
|
458
|
+
share: percent.value.format(root && root.value > 0 ? node.value / root.value : 0)
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** The tree's names in it, where they fit: a leaf's in its middle, a group's in its header. */
|
|
462
|
+
const treeLabels = computed(() => {
|
|
463
|
+
const l = treeLayout.value;
|
|
464
|
+
const part = tree.value;
|
|
465
|
+
const out = [];
|
|
466
|
+
if (!l || !part || !part.props.labels || empty.value) return out;
|
|
467
|
+
const colorsOf = treeColors.value;
|
|
468
|
+
const given = part.props.labels;
|
|
469
|
+
const whole = treeData.value?.root.value ?? 0;
|
|
470
|
+
for (const node of l.nodes) {
|
|
471
|
+
if (node.depth === 0) continue;
|
|
472
|
+
const text = typeof given === "function" ? given({
|
|
473
|
+
value: node.value,
|
|
474
|
+
share: whole > 0 ? node.value / whole : 0,
|
|
475
|
+
name: node.name,
|
|
476
|
+
row: node.row ?? {},
|
|
477
|
+
index: node.place,
|
|
478
|
+
depth: node.depth
|
|
479
|
+
}) : node.name;
|
|
480
|
+
if (text == null || text === "") continue;
|
|
481
|
+
const name = String(text);
|
|
482
|
+
const width = measure(name);
|
|
483
|
+
if (part.shape === "treemap") {
|
|
484
|
+
const w = node.x1 - node.x0;
|
|
485
|
+
const h = node.y1 - node.y0;
|
|
486
|
+
if (node.children.length) {
|
|
487
|
+
if (!hasHeader(node) || width > w - 12) continue;
|
|
488
|
+
out.push({
|
|
489
|
+
key: `node:${node.index}`,
|
|
490
|
+
series: -1,
|
|
491
|
+
text: name,
|
|
492
|
+
left: node.x0 + 6,
|
|
493
|
+
top: node.y0 + 2,
|
|
494
|
+
align: "start",
|
|
495
|
+
ink: "var(--dou-color-text-secondary)"
|
|
496
|
+
});
|
|
497
|
+
} else if (width <= w - 12 && h >= 24) out.push({
|
|
498
|
+
key: `node:${node.index}`,
|
|
499
|
+
series: -1,
|
|
500
|
+
text: name,
|
|
501
|
+
left: node.x0 + 6,
|
|
502
|
+
top: node.y0 + 6,
|
|
503
|
+
align: "start",
|
|
504
|
+
on: colorsOf[node.index],
|
|
505
|
+
ink: inkOn(colorsOf[node.index])
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const r = (node.y0 + node.y1) / 2;
|
|
510
|
+
if ((node.x1 - node.x0) * r < width + 8 || node.y1 - node.y0 < 20) continue;
|
|
511
|
+
const middle = (node.x0 + node.x1) / 2;
|
|
512
|
+
out.push({
|
|
513
|
+
key: `node:${node.index}`,
|
|
514
|
+
series: -1,
|
|
515
|
+
text: name,
|
|
516
|
+
left: l.cx + Math.sin(middle) * r,
|
|
517
|
+
top: l.cy - Math.cos(middle) * r - 8,
|
|
518
|
+
align: "middle",
|
|
519
|
+
on: colorsOf[node.index],
|
|
520
|
+
ink: inkOn(colorsOf[node.index])
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
});
|
|
525
|
+
/** The node the tooltip shows, its path as the title. */
|
|
526
|
+
function treeTip(index, pointer) {
|
|
527
|
+
const l = treeLayout.value;
|
|
528
|
+
const node = treeData.value?.nodes[index];
|
|
529
|
+
if (!l || !node || node.depth === 0) return null;
|
|
530
|
+
const anchor = tree.value?.shape === "sunburst" ? [l.cx + Math.sin((node.x0 + node.x1) / 2) * ((node.y0 + node.y1) / 2), l.cy - Math.cos((node.x0 + node.x1) / 2) * ((node.y0 + node.y1) / 2)] : [(node.x0 + node.x1) / 2, (node.y0 + node.y1) / 2];
|
|
531
|
+
return {
|
|
532
|
+
x: node.name,
|
|
533
|
+
title: treePath(node).join(" / "),
|
|
534
|
+
items: [{
|
|
535
|
+
name: tree.value?.props.name ?? messages.value.tableValue,
|
|
536
|
+
text: treeValue(node),
|
|
537
|
+
value: node.value,
|
|
538
|
+
color: treeColors.value[node.index],
|
|
539
|
+
row: node.row ?? void 0
|
|
540
|
+
}],
|
|
541
|
+
anchor: pointer ?? anchor,
|
|
542
|
+
marks: []
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The tree as drawn with nothing pointed at, kept while its layout and
|
|
547
|
+
* colors stay: pointing at a node then draws only that node's part over
|
|
548
|
+
* it (a treemap's ring, a sunburst's branch), however many nodes.
|
|
549
|
+
*/
|
|
550
|
+
let treeImage = null;
|
|
551
|
+
/** The kept image of the tree, drawn again when `key` changes; null without a canvas. */
|
|
552
|
+
function treeImageOf(key, dpr, render) {
|
|
553
|
+
const l = treeLayout.value;
|
|
554
|
+
if (treeImage && key.every((k, i) => k === treeImage.key[i])) return treeImage.canvas;
|
|
555
|
+
const canvas = treeImage?.canvas ?? document.createElement("canvas");
|
|
556
|
+
canvas.width = Math.round(l.width * dpr);
|
|
557
|
+
canvas.height = Math.round(l.height * dpr);
|
|
558
|
+
const ctx = canvas.getContext("2d");
|
|
559
|
+
if (!ctx) return null;
|
|
560
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
561
|
+
ctx.clearRect(0, 0, l.width, l.height);
|
|
562
|
+
render(ctx);
|
|
563
|
+
treeImage = {
|
|
564
|
+
key,
|
|
565
|
+
canvas
|
|
566
|
+
};
|
|
567
|
+
return canvas;
|
|
568
|
+
}
|
|
569
|
+
/** Draws the tree, as it comes in: a treemap fades in, a sunburst sweeps round. */
|
|
570
|
+
function paintTree(painter, t, still = false, dpr = 1) {
|
|
571
|
+
const l = treeLayout.value;
|
|
572
|
+
const a = still ? null : active.value;
|
|
573
|
+
const focus = a?.type === "slice" ? a.slice : -1;
|
|
574
|
+
const shape = tree.value?.shape;
|
|
575
|
+
const surface = colors.value.surface;
|
|
576
|
+
const ring = resolveColor("var(--dou-color-text)");
|
|
577
|
+
const sunburst = {
|
|
578
|
+
cx: l.cx,
|
|
579
|
+
cy: l.cy,
|
|
580
|
+
surface
|
|
581
|
+
};
|
|
582
|
+
const direct = () => shape === "treemap" ? drawTreemap(painter, l.nodes, treeColors.value, {
|
|
583
|
+
surface,
|
|
584
|
+
ring,
|
|
585
|
+
t,
|
|
586
|
+
focus
|
|
587
|
+
}) : drawSunburst(painter, l.nodes, treeColors.value, {
|
|
588
|
+
...sunburst,
|
|
589
|
+
focus,
|
|
590
|
+
sweep: t
|
|
591
|
+
});
|
|
592
|
+
if (still || t < 1 || isSvg(painter) || typeof document === "undefined") return direct();
|
|
593
|
+
const image = treeImageOf([
|
|
594
|
+
shape,
|
|
595
|
+
l,
|
|
596
|
+
treeColors.value,
|
|
597
|
+
surface,
|
|
598
|
+
dpr
|
|
599
|
+
], dpr, (ctx) => shape === "treemap" ? drawTreemap(ctx, l.nodes, treeColors.value, {
|
|
600
|
+
surface,
|
|
601
|
+
ring,
|
|
602
|
+
t
|
|
603
|
+
}) : drawSunburst(ctx, l.nodes, treeColors.value, sunburst));
|
|
604
|
+
if (!image) return direct();
|
|
605
|
+
const node = l.nodes[focus];
|
|
606
|
+
if (shape === "treemap") {
|
|
607
|
+
painter.drawImage(image, 0, 0, l.width, l.height);
|
|
608
|
+
ringTreemap(painter, node, ring);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (!node) {
|
|
612
|
+
painter.drawImage(image, 0, 0, l.width, l.height);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
painter.save();
|
|
616
|
+
painter.globalAlpha *= SUNBURST_FADE;
|
|
617
|
+
painter.drawImage(image, 0, 0, l.width, l.height);
|
|
618
|
+
painter.restore();
|
|
619
|
+
drawSunburst(painter, sunburstBranch(node), treeColors.value, sunburst);
|
|
620
|
+
}
|
|
621
|
+
/** The chart is a funnel: its first funnel's stages, one under another. */
|
|
622
|
+
const funnel = computed(() => !pie.value && seriesParts.value.some((part) => part.kind.funnel));
|
|
623
|
+
/** The chart is a radar: its series are drawn round a center. */
|
|
624
|
+
const radar = computed(() => !pie.value && !funnel.value && seriesParts.value.some((part) => part.kind.polar));
|
|
625
|
+
if (process.env.NODE_ENV !== "production") watch(seriesParts, (list) => {
|
|
626
|
+
const polar = list.filter((part) => part.kind.polar).length;
|
|
627
|
+
if (polar && polar < list.length) console.warn("[@dou-ui/chart] a <DouChartRadar> is drawn round a center: series on axes are left out of it.");
|
|
628
|
+
if (list.some((part) => part.kind.funnel) && list.length > 1) console.warn("[@dou-ui/chart] a <DouChartFunnel> is drawn alone: other series are left out.");
|
|
629
|
+
});
|
|
630
|
+
/** No series has a value to show. */
|
|
631
|
+
const empty = computed(() => pie.value ? !shownSlices.value.length : tree.value ? !(treeData.value && treeData.value.root.value > 0) : !model.value.y.y && !model.value.y.y2);
|
|
632
|
+
let timestampWarned = false;
|
|
633
|
+
watch(model, (m) => {
|
|
634
|
+
if (process.env.NODE_ENV === "production" || timestampWarned) return;
|
|
635
|
+
if (m.xKind !== "linear" || axes.value.x.type || !m.x) return;
|
|
636
|
+
if (m.x[0] > 1e11 && m.x[1] < 0x9184e72a000) {
|
|
637
|
+
timestampWarned = true;
|
|
638
|
+
console.warn("[@dou-ui/chart] the x values look like times in ms. Add <DouChartAxis for=\"x\" type=\"time\" /> to show them as dates.");
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
const TONES = /* @__PURE__ */ new Set([
|
|
642
|
+
"neutral",
|
|
643
|
+
"primary",
|
|
644
|
+
"success",
|
|
645
|
+
"warning",
|
|
646
|
+
"danger"
|
|
647
|
+
]);
|
|
648
|
+
/** A color given as a CSS color or a tone name, as CSS. */
|
|
649
|
+
const toneOrColor = (color) => color === "neutral" ? "var(--dou-color-chart-1)" : TONES.has(color) ? `var(--dou-color-${color})` : color;
|
|
650
|
+
/** The chart's own palette (`palette`), when one is given. */
|
|
651
|
+
const ownPalette = computed(() => props.palette?.length ? props.palette : null);
|
|
652
|
+
/** The palette's color for the series at `index`: the chart's own, or the theme's eight. */
|
|
653
|
+
function paletteColor(index) {
|
|
654
|
+
const own = ownPalette.value;
|
|
655
|
+
return own ? toneOrColor(own[index % own.length]) : `var(--dou-color-chart-${index % 8 + 1})`;
|
|
656
|
+
}
|
|
657
|
+
/** A series' color: its own, its tone's, or the palette's by its place among all series. */
|
|
658
|
+
function seriesColor(p, index) {
|
|
659
|
+
const own = typeof p.color === "string" ? p.color : void 0;
|
|
660
|
+
return resolveColor(own ?? (p.tone ? `var(--dou-color-${p.tone})` : paletteColor(index)));
|
|
661
|
+
}
|
|
662
|
+
/** A color given as a CSS color or a tone name, as the canvas takes it. */
|
|
663
|
+
const itemColor = (color) => resolveColor(toneOrColor(color));
|
|
664
|
+
/**
|
|
665
|
+
* A waterfall's colors, unless it has its own: rises in the success tone,
|
|
666
|
+
* falls in the danger tone, totals in the series' color.
|
|
667
|
+
*/
|
|
668
|
+
function waterfallColors(s) {
|
|
669
|
+
if (typeof s.props.color === "string") return null;
|
|
670
|
+
const rise = itemColor("success");
|
|
671
|
+
const fall = itemColor("danger");
|
|
672
|
+
const out = [];
|
|
673
|
+
for (let i = 0; i < s.y.length; i++) out.push(isTotal(s, i) ? void 0 : s.y[i] < 0 ? fall : rise);
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
/** The colors of single bars or points, by row, when `color` is a function. */
|
|
677
|
+
function itemColors(s) {
|
|
678
|
+
const of = s.props.color;
|
|
679
|
+
if (typeof of !== "function") return s.kind.waterfall ? waterfallColors(s) : null;
|
|
680
|
+
const out = [];
|
|
681
|
+
const seen = /* @__PURE__ */ new Map();
|
|
682
|
+
for (let i = 0; i < s.y.length; i++) {
|
|
683
|
+
const color = of(rowOf(s.rows, i), i);
|
|
684
|
+
if (!color) {
|
|
685
|
+
out.push(void 0);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
let css = seen.get(color);
|
|
689
|
+
if (css === void 0) seen.set(color, css = itemColor(color));
|
|
690
|
+
out.push(css);
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
const series = computed(() => {
|
|
695
|
+
themeVersion.value;
|
|
696
|
+
return model.value.series.map((s) => {
|
|
697
|
+
const { props: p, index } = s.input;
|
|
698
|
+
const shown = {
|
|
699
|
+
...s,
|
|
700
|
+
index,
|
|
701
|
+
kind: s.input.kind,
|
|
702
|
+
props: p,
|
|
703
|
+
color: seriesColor(p, index),
|
|
704
|
+
pattern: seriesPattern(p, s.input.kind, index)
|
|
705
|
+
};
|
|
706
|
+
shown.colors = itemColors(shown);
|
|
707
|
+
return shown;
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
/**
|
|
711
|
+
* The kinds whose fill can take a pattern: bars (a histogram's too) and
|
|
712
|
+
* areas. A waterfall's colors say which way each step goes, and a box
|
|
713
|
+
* plot's are light: they take none.
|
|
714
|
+
*/
|
|
715
|
+
const FILLED = /* @__PURE__ */ new Set(["bar", "area"]);
|
|
716
|
+
/** A series' pattern: its own, or with the chart's `patterns`, one by its place. */
|
|
717
|
+
function seriesPattern(p, kind, index) {
|
|
718
|
+
if (!FILLED.has(kind.type) || kind.waterfall) return null;
|
|
719
|
+
const own = p.pattern;
|
|
720
|
+
if (own) return own === "none" ? null : own;
|
|
721
|
+
return props.patterns ? CHART_PATTERNS[index % CHART_PATTERNS.length] : null;
|
|
722
|
+
}
|
|
723
|
+
/** A pattern as a legend swatch shows it, over the swatch's color. */
|
|
724
|
+
const swatchPattern = (pattern) => pattern ? patternCss(pattern, "color-mix(in oklab, var(--dou-chart-surface) 70%, transparent)") : void 0;
|
|
725
|
+
/** Each slice's pattern, by its place, with the chart's `patterns`. */
|
|
726
|
+
const slicePatterns = computed(() => props.patterns && !pie.value?.gauge ? slices.value.map((slice) => CHART_PATTERNS[slice.index % CHART_PATTERNS.length]) : void 0);
|
|
727
|
+
/** The colored part of the palette: slices leave the neutral first color for "other". */
|
|
728
|
+
const sliceColors = computed(() => {
|
|
729
|
+
themeVersion.value;
|
|
730
|
+
const palette = ownPalette.value;
|
|
731
|
+
return slices.value.map((slice) => {
|
|
732
|
+
const own = slice.color;
|
|
733
|
+
const css = own ? toneOrColor(own) : palette ? toneOrColor(palette[slice.index % palette.length]) : `var(--dou-color-chart-${slice.index % SLICE_COLORS + 2})`;
|
|
734
|
+
return resolveColor(css);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
if (process.env.NODE_ENV !== "production") watch(slices, (list) => {
|
|
738
|
+
const count = ownPalette.value?.length ?? SLICE_COLORS;
|
|
739
|
+
if (list.length > count && pie.value && pie.value.props.color === void 0) console.warn(`[@dou-ui/chart] a pie of ${list.length} slices repeats colors after ${count}: merge the small ones into one slice, or give each a \`color\`.`);
|
|
740
|
+
});
|
|
741
|
+
/** The legend: one entry per series, or per slice of a pie, turned off or not. */
|
|
742
|
+
const legendEntries = computed(() => {
|
|
743
|
+
if (empty.value) return [];
|
|
744
|
+
themeVersion.value;
|
|
745
|
+
if (tree.value) return [];
|
|
746
|
+
if (pie.value) {
|
|
747
|
+
if (pie.value.gauge || !(props.legend ?? slices.value.length > 1)) return [];
|
|
748
|
+
const last = shownSlices.value.length === 1;
|
|
749
|
+
return slices.value.map((slice, k) => ({
|
|
750
|
+
text: slice.label,
|
|
751
|
+
color: sliceColors.value[k],
|
|
752
|
+
kind: "pie",
|
|
753
|
+
dashed: false,
|
|
754
|
+
off: slice.hidden,
|
|
755
|
+
locked: last && !slice.hidden,
|
|
756
|
+
scale: void 0,
|
|
757
|
+
pattern: swatchPattern(slicePatterns.value?.[slice.index])
|
|
758
|
+
}));
|
|
759
|
+
}
|
|
760
|
+
const scaled = seriesParts.value.some((part) => part.kind.categoryY);
|
|
761
|
+
if (!(props.legend ?? (seriesParts.value.length > 1 || scaled))) return [];
|
|
762
|
+
const last = seriesParts.value.filter((part) => !part.hidden.value).length === 1;
|
|
763
|
+
return seriesParts.value.map((part, index) => {
|
|
764
|
+
const color = seriesColor(part.props, index);
|
|
765
|
+
const entry = {
|
|
766
|
+
text: seriesName({
|
|
767
|
+
index,
|
|
768
|
+
props: part.props
|
|
769
|
+
}),
|
|
770
|
+
color,
|
|
771
|
+
kind: part.kind.type,
|
|
772
|
+
dashed: !!part.props.dashed,
|
|
773
|
+
off: part.hidden.value,
|
|
774
|
+
locked: last && !part.hidden.value,
|
|
775
|
+
/** A heatmap's scale: its colors, least to most. */
|
|
776
|
+
scale: void 0,
|
|
777
|
+
pattern: swatchPattern(seriesPattern(part.props, part.kind, index))
|
|
778
|
+
};
|
|
779
|
+
const s = part.kind.categoryY ? series.value.find((one) => one.index === index) : void 0;
|
|
780
|
+
const range = s && heatRange(s);
|
|
781
|
+
if (s && range) {
|
|
782
|
+
const text = heatText(s);
|
|
783
|
+
const surface = colors.value.surface;
|
|
784
|
+
entry.text = `${text(range[0])} – ${text(range[1])}`;
|
|
785
|
+
entry.scale = [
|
|
786
|
+
0,
|
|
787
|
+
.5,
|
|
788
|
+
1
|
|
789
|
+
].map((t) => heatColor(s.color, surface, t));
|
|
790
|
+
}
|
|
791
|
+
return entry;
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
/** The legend's names for the layout: a heatmap's scale takes a wider swatch. */
|
|
795
|
+
const legendNames = computed(() => legendEntries.value.map((entry) => entry.scale ? {
|
|
796
|
+
text: entry.text,
|
|
797
|
+
swatch: 40
|
|
798
|
+
} : entry.text));
|
|
799
|
+
/** Turns an entry's series (or slice) on or off; the last one shown stays on. */
|
|
800
|
+
function toggle(index) {
|
|
801
|
+
const pieChart = pie.value;
|
|
802
|
+
if (pieChart) {
|
|
803
|
+
const slice = slices.value[index];
|
|
804
|
+
if (!slice) return;
|
|
805
|
+
const hidden = pieChart.hidden.value;
|
|
806
|
+
if (slice.hidden) pieChart.hidden.value = hidden.filter((label) => label !== slice.label);
|
|
807
|
+
else if (shownSlices.value.length > 1) pieChart.hidden.value = [...hidden, slice.label];
|
|
808
|
+
} else {
|
|
809
|
+
const part = seriesParts.value[index];
|
|
810
|
+
if (!part) return;
|
|
811
|
+
if (part.hidden.value) part.hidden.value = false;
|
|
812
|
+
else if (seriesParts.value.filter((p) => !p.hidden.value).length > 1) part.hidden.value = true;
|
|
813
|
+
}
|
|
814
|
+
if (focused.value === index) focused.value = null;
|
|
815
|
+
}
|
|
816
|
+
/** The legend entry the pointer or focus is on: its series is brought forward. */
|
|
817
|
+
const focused = ref(null);
|
|
818
|
+
/**
|
|
819
|
+
* Shows only the entry's series (or slice), or all of them again when it
|
|
820
|
+
* already is the only one: its legend entry clicked with Alt, held down on
|
|
821
|
+
* a touch screen, or Alt+Enter.
|
|
822
|
+
*/
|
|
823
|
+
function isolate(index) {
|
|
824
|
+
const pieChart = pie.value;
|
|
825
|
+
if (pieChart) {
|
|
826
|
+
const slice = slices.value[index];
|
|
827
|
+
if (!slice) return;
|
|
828
|
+
const alone = shownSlices.value.length === 1 && !slice.hidden;
|
|
829
|
+
pieChart.hidden.value = alone ? [] : slices.value.filter((other) => other !== slice).map((other) => other.label);
|
|
830
|
+
} else {
|
|
831
|
+
const parts = seriesParts.value;
|
|
832
|
+
const part = parts[index];
|
|
833
|
+
if (!part) return;
|
|
834
|
+
const alone = !part.hidden.value && parts.every((p) => p === part || p.hidden.value);
|
|
835
|
+
for (const p of parts) p.hidden.value = alone ? false : p !== part;
|
|
836
|
+
}
|
|
837
|
+
focused.value = null;
|
|
838
|
+
}
|
|
839
|
+
const zoomPart = computed(() => !pie.value && zoomParts.value[0] || null);
|
|
840
|
+
const rangePart = computed(() => !pie.value && rangeParts.value[0] || null);
|
|
841
|
+
const brushPart = computed(() => {
|
|
842
|
+
const part = brushParts.value[0];
|
|
843
|
+
return !pie.value && part && !part.props.disabled ? part : null;
|
|
844
|
+
});
|
|
845
|
+
/** Whether the chart zooms with the wheel, fingers and keys. */
|
|
846
|
+
const zoomOn = computed(() => !!zoomPart.value && !zoomPart.value.props.disabled && !empty.value);
|
|
847
|
+
const zoomAxes = computed(() => {
|
|
848
|
+
const axis = zoomPart.value?.props.axis ?? "x";
|
|
849
|
+
return {
|
|
850
|
+
x: zoomOn.value && axis !== "y",
|
|
851
|
+
y: zoomOn.value && axis !== "x"
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
/** The models the stretch of x is kept in: the zoom's and the strip's. */
|
|
855
|
+
const xModels = computed(() => {
|
|
856
|
+
const list = [];
|
|
857
|
+
if (zoomPart.value && !zoomPart.value.props.disabled) list.push(zoomPart.value.x);
|
|
858
|
+
if (rangePart.value) list.push(rangePart.value.x);
|
|
859
|
+
return list;
|
|
860
|
+
});
|
|
861
|
+
const xWindow = shallowRef(null);
|
|
862
|
+
const yWindow = shallowRef(null);
|
|
863
|
+
/** What was last given to the models, to tell it from a value set by the page. */
|
|
864
|
+
let sentX = null;
|
|
865
|
+
let sentY = null;
|
|
866
|
+
watch(() => [xModels.value[0]?.value, model.value], ([value]) => {
|
|
867
|
+
const raw = value ? toRaw(value) : null;
|
|
868
|
+
if (raw === sentX && raw !== null) return;
|
|
869
|
+
const m = model.value;
|
|
870
|
+
xWindow.value = intervalToWindow(raw, m.xKind, m.categories);
|
|
871
|
+
}, { immediate: true });
|
|
872
|
+
watch(() => zoomAxes.value.y ? zoomPart.value?.y.value : null, (value) => {
|
|
873
|
+
const raw = value ? toRaw(value) : null;
|
|
874
|
+
if (raw === sentY && raw !== null) return;
|
|
875
|
+
yWindow.value = intervalToWindow(raw, "linear", []);
|
|
876
|
+
}, { immediate: true });
|
|
877
|
+
/** The y axis that zooming the values follows: `y`, or `y2` when it is alone. */
|
|
878
|
+
const primaryY = (l) => l.y.y ?? l.y.y2;
|
|
879
|
+
/** The least that can be shown: one category, a few of the closest x values, a thousandth of the values. */
|
|
880
|
+
const leastX = computed(() => {
|
|
881
|
+
const m = model.value;
|
|
882
|
+
if (m.xKind === "category") return 1;
|
|
883
|
+
let gap = Infinity;
|
|
884
|
+
for (const s of m.series) {
|
|
885
|
+
const g = smallestGap(s.x.values);
|
|
886
|
+
if (g < gap) gap = g;
|
|
887
|
+
}
|
|
888
|
+
const span = m.x ? m.x[1] - m.x[0] : 1;
|
|
889
|
+
return gap < Infinity ? gap * 4 : span / 1e3;
|
|
890
|
+
});
|
|
891
|
+
const sameInterval = (a, b) => a === b || !!a && !!b && a.every((v, i) => +v === +b[i] || v === b[i] && typeof v === "string");
|
|
892
|
+
/** Shows `window` of x (`null` for all of it); `settle` moves a drag over categories to whole ones. */
|
|
893
|
+
function showX(window, settle = false) {
|
|
894
|
+
const l = layout.value;
|
|
895
|
+
if (!l) return;
|
|
896
|
+
const m = model.value;
|
|
897
|
+
const category = m.xKind === "category";
|
|
898
|
+
let next = window && clampWindow(window, l.x.full, leastX.value);
|
|
899
|
+
if (next && settle && category) next = clampWindow(snapCategories(next), l.x.full, 1);
|
|
900
|
+
xWindow.value = next;
|
|
901
|
+
let interval = next ? windowToInterval(next, m.xKind, m.categories) : null;
|
|
902
|
+
if (interval && next && m.xKind === "linear") interval = rounded(next);
|
|
903
|
+
const last = sentX;
|
|
904
|
+
for (const kept of xModels.value) {
|
|
905
|
+
if (sameInterval(interval, kept.value) && sameInterval(interval, last)) continue;
|
|
906
|
+
sentX = interval;
|
|
907
|
+
kept.value = interval;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/** Shows `window` of the values (on `y`, or `y2` alone); `null` for all of them. */
|
|
911
|
+
function showY(window) {
|
|
912
|
+
const l = layout.value;
|
|
913
|
+
const axis = l && primaryY(l);
|
|
914
|
+
const part = zoomPart.value;
|
|
915
|
+
if (!axis || !part) return;
|
|
916
|
+
const units = unitsOf(axis.scale);
|
|
917
|
+
const span = units.to(axis.full[1]) - units.to(axis.full[0]);
|
|
918
|
+
const next = window && clampWindow(window, axis.full, span / 1e3, units);
|
|
919
|
+
yWindow.value = next;
|
|
920
|
+
const interval = next ? rounded(next) : null;
|
|
921
|
+
if (sameInterval(interval, part.y.value) && sameInterval(interval, sentY)) return;
|
|
922
|
+
sentY = interval;
|
|
923
|
+
part.y.value = interval;
|
|
924
|
+
}
|
|
925
|
+
/** A window of numbers as given back: a thousandth of its span is as fine as it gets, not 62.1344726775542. */
|
|
926
|
+
function rounded([a, b]) {
|
|
927
|
+
const span = Math.abs(b - a);
|
|
928
|
+
const digits = span > 0 ? Math.min(12, Math.max(0, Math.ceil(-Math.log10(span / 1e3)))) : 0;
|
|
929
|
+
return [Number(a.toFixed(digits)), Number(b.toFixed(digits))];
|
|
930
|
+
}
|
|
931
|
+
const zoomed = computed(() => !!xWindow.value || !!yWindow.value);
|
|
932
|
+
/** Where a point is along x, and along the values, in px. */
|
|
933
|
+
const alongX = ([px, py]) => props.horizontal ? py : px;
|
|
934
|
+
const alongY = ([px, py]) => props.horizontal ? px : py;
|
|
935
|
+
/** A scale like `like` over `window`. */
|
|
936
|
+
function scaleOver(window, like) {
|
|
937
|
+
if (like.kind === "log") return logScale(window, like.range);
|
|
938
|
+
return linearScale(window, like.range);
|
|
939
|
+
}
|
|
940
|
+
function scalesNow() {
|
|
941
|
+
const l = layout.value;
|
|
942
|
+
return l && {
|
|
943
|
+
x: l.x.scale,
|
|
944
|
+
y: primaryY(l)?.scale ?? null
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/** Zooms by `factor` (below 1 is in) about `at`; from `from`, the scales as they were. */
|
|
948
|
+
function zoomBy(factor, at, from = scalesNow()) {
|
|
949
|
+
if (!from) return;
|
|
950
|
+
if (zoomAxes.value.x) showX(zoomWindow(from.x, alongX(at), factor));
|
|
951
|
+
if (zoomAxes.value.y && from.y) showY(zoomWindow(from.y, alongY(at), factor));
|
|
952
|
+
}
|
|
953
|
+
/** Moves what is shown with a drag of `by` px; from `from`, the scales as the drag started. */
|
|
954
|
+
function panBy(by, from = scalesNow()) {
|
|
955
|
+
if (!from) return;
|
|
956
|
+
if (zoomAxes.value.x && xWindow.value) showX(panWindow(from.x, -alongX(by)));
|
|
957
|
+
if (zoomAxes.value.y && yWindow.value && from.y) showY(panWindow(from.y, -alongY(by)));
|
|
958
|
+
}
|
|
959
|
+
function resetZoom(announce = false) {
|
|
960
|
+
if (!zoomed.value) return;
|
|
961
|
+
showX(null);
|
|
962
|
+
if (zoomPart.value) showY(null);
|
|
963
|
+
if (announce) zoomNote.value = messages.value.zoomReset;
|
|
964
|
+
}
|
|
965
|
+
/** Read out after zooming with the keys. */
|
|
966
|
+
const zoomNote = ref("");
|
|
967
|
+
/** Below this width, a legend down the right side goes under the chart: it would leave the plot too narrow. */
|
|
968
|
+
const legendPlacement = computed(() => {
|
|
969
|
+
const asked = props.legendPlacement ?? "bottom";
|
|
970
|
+
return asked === "right" && size.width.value && size.width.value < LEGEND_SIDE_LEAST ? "bottom" : asked;
|
|
971
|
+
});
|
|
972
|
+
const layout = computed(() => {
|
|
973
|
+
const width = size.width.value;
|
|
974
|
+
const height = size.height.value;
|
|
975
|
+
if (!width || !height || pie.value || tree.value || radar.value || funnel.value) return null;
|
|
976
|
+
themeVersion.value;
|
|
977
|
+
return layoutChart({
|
|
978
|
+
model: model.value,
|
|
979
|
+
axes: axes.value,
|
|
980
|
+
horizontal: props.horizontal,
|
|
981
|
+
width,
|
|
982
|
+
height,
|
|
983
|
+
locale: locale.value.name,
|
|
984
|
+
measure,
|
|
985
|
+
legend: legendNames.value,
|
|
986
|
+
coarse: coarse.value,
|
|
987
|
+
legendPlacement: legendPlacement.value,
|
|
988
|
+
zoom: {
|
|
989
|
+
x: xWindow.value,
|
|
990
|
+
y: zoomAxes.value.y ? yWindow.value : null
|
|
991
|
+
},
|
|
992
|
+
strip: !!rangePart.value && !empty.value,
|
|
993
|
+
trailing: endLabelRoom.value
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
const pieLayout = computed(() => {
|
|
997
|
+
const width = size.width.value;
|
|
998
|
+
const height = size.height.value;
|
|
999
|
+
if (!width || !height || !pie.value) return null;
|
|
1000
|
+
themeVersion.value;
|
|
1001
|
+
return layoutPie({
|
|
1002
|
+
width,
|
|
1003
|
+
height,
|
|
1004
|
+
measure,
|
|
1005
|
+
legend: legendNames.value,
|
|
1006
|
+
coarse: coarse.value,
|
|
1007
|
+
legendPlacement: legendPlacement.value,
|
|
1008
|
+
sideLabels: pieLabelsOutside.value ? pieSideWidths.value : void 0,
|
|
1009
|
+
arc: pieArc.value
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
const radarLayout = computed(() => {
|
|
1013
|
+
const width = size.width.value;
|
|
1014
|
+
const height = size.height.value;
|
|
1015
|
+
if (!width || !height || !radar.value) return null;
|
|
1016
|
+
themeVersion.value;
|
|
1017
|
+
const m = model.value;
|
|
1018
|
+
return layoutRadar({
|
|
1019
|
+
model: m,
|
|
1020
|
+
axes: axes.value,
|
|
1021
|
+
width,
|
|
1022
|
+
height,
|
|
1023
|
+
locale: locale.value.name,
|
|
1024
|
+
measure,
|
|
1025
|
+
legend: legendNames.value,
|
|
1026
|
+
coarse: coarse.value,
|
|
1027
|
+
legendPlacement: legendPlacement.value,
|
|
1028
|
+
spokes: m.categories.map((_, i) => xText(i))
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
/** The funnel drawn: the first one, if the chart is a funnel. */
|
|
1032
|
+
const funnelSeries = computed(() => funnel.value ? series.value.find((s) => s.kind.funnel) ?? null : null);
|
|
1033
|
+
/** Each stage's value (`NaN` without one), and its share of the stage before it and of the first. */
|
|
1034
|
+
const funnelStages = computed(() => {
|
|
1035
|
+
const s = funnelSeries.value;
|
|
1036
|
+
const n = model.value.categories.length;
|
|
1037
|
+
const values = Array.from({ length: n }, () => NaN);
|
|
1038
|
+
if (s) for (let i = 0; i < s.y.length; i++) {
|
|
1039
|
+
const x = s.x.values[i];
|
|
1040
|
+
if (x === x && s.y[i] === s.y[i]) values[x] = s.y[i];
|
|
1041
|
+
}
|
|
1042
|
+
const first = values.find((v) => v === v);
|
|
1043
|
+
return values.map((value, k) => {
|
|
1044
|
+
const before = k > 0 ? values[k - 1] : NaN;
|
|
1045
|
+
return {
|
|
1046
|
+
value,
|
|
1047
|
+
row: s ? rowAt(s.x.values, k) : -1,
|
|
1048
|
+
step: k > 0 && before > 0 && value === value ? value / before : null,
|
|
1049
|
+
total: first !== void 0 && first > 0 && value === value ? value / first : null
|
|
1050
|
+
};
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
const funnelLayout = computed(() => {
|
|
1054
|
+
const width = size.width.value;
|
|
1055
|
+
const height = size.height.value;
|
|
1056
|
+
const s = funnelSeries.value;
|
|
1057
|
+
if (!width || !height || !s) return null;
|
|
1058
|
+
themeVersion.value;
|
|
1059
|
+
const stages = funnelStages.value;
|
|
1060
|
+
return layoutFunnel({
|
|
1061
|
+
width,
|
|
1062
|
+
height,
|
|
1063
|
+
measure,
|
|
1064
|
+
legend: legendNames.value,
|
|
1065
|
+
coarse: coarse.value,
|
|
1066
|
+
legendPlacement: legendPlacement.value,
|
|
1067
|
+
stages: model.value.categories.map((_, i) => xText(i)),
|
|
1068
|
+
values: stages.map((stage) => stage.row < 0 ? "" : seriesText(s, stage.row)),
|
|
1069
|
+
rates: stages.map((stage) => stage.step === null ? null : rate.value.format(stage.step)),
|
|
1070
|
+
max: Math.max(0, ...stages.map((stage) => stage.value === stage.value ? stage.value : 0))
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
/** A marker's x as a number on the x scale: one, or the two ends of a band; `undefined` when it can't be read. */
|
|
1074
|
+
function markerX(x) {
|
|
1075
|
+
if (x === void 0 || x === null) return null;
|
|
1076
|
+
const m = model.value;
|
|
1077
|
+
if (Array.isArray(x)) {
|
|
1078
|
+
const [a, b] = x;
|
|
1079
|
+
if (m.xKind !== "category" && valueToNumber(a, m.xKind, m.categories) === valueToNumber(b, m.xKind, m.categories)) return markerX(a);
|
|
1080
|
+
return intervalToWindow(x, m.xKind, m.categories) ?? void 0;
|
|
1081
|
+
}
|
|
1082
|
+
const at = valueToNumber(x, m.xKind, m.categories);
|
|
1083
|
+
return at === at ? at : void 0;
|
|
1084
|
+
}
|
|
1085
|
+
const placedMarkers = computed(() => {
|
|
1086
|
+
const l = layout.value;
|
|
1087
|
+
if (!l || empty.value || !markerParts.value.length) return [];
|
|
1088
|
+
const inputs = [];
|
|
1089
|
+
for (const part of markerParts.value) {
|
|
1090
|
+
const p = part.props;
|
|
1091
|
+
const x = markerX(p.x);
|
|
1092
|
+
if (x === void 0) continue;
|
|
1093
|
+
inputs.push({
|
|
1094
|
+
x,
|
|
1095
|
+
y: p.y ?? null,
|
|
1096
|
+
axis: p.axis ?? defaultAxis.value,
|
|
1097
|
+
label: p.label,
|
|
1098
|
+
tone: p.tone ?? "neutral",
|
|
1099
|
+
solid: p.dashed === false
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
return placeMarkers(inputs, {
|
|
1103
|
+
plot: l.plot,
|
|
1104
|
+
x: l.x.scale,
|
|
1105
|
+
y: (axis) => l.y[axis]?.scale ?? null,
|
|
1106
|
+
horizontal: !!props.horizontal,
|
|
1107
|
+
measure
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
/** The markers' labels, as text over the chart. */
|
|
1111
|
+
const markerLabels = computed(() => placedMarkers.value.flatMap((marker, i) => marker.label ? [{
|
|
1112
|
+
...marker.label,
|
|
1113
|
+
key: `marker:${i}`,
|
|
1114
|
+
tone: marker.tone,
|
|
1115
|
+
backdrop: true
|
|
1116
|
+
}] : []));
|
|
1117
|
+
function labelText(s, row) {
|
|
1118
|
+
const given = s.props.labels;
|
|
1119
|
+
const value = s.y[row];
|
|
1120
|
+
if (value !== value) return null;
|
|
1121
|
+
const share = s.stackEnds && declaredAxes.value[s.axis]?.percent ? Math.abs(s.top[row] - s.base[row]) : void 0;
|
|
1122
|
+
if (typeof given === "function") {
|
|
1123
|
+
const text = given({
|
|
1124
|
+
value,
|
|
1125
|
+
share,
|
|
1126
|
+
low: s.base && !s.stackEnds && !s.kind.waterfall ? s.base[row] : void 0,
|
|
1127
|
+
name: s.name,
|
|
1128
|
+
row: rowOf(s.rows, row),
|
|
1129
|
+
index: row
|
|
1130
|
+
});
|
|
1131
|
+
return text == null || text === "" ? null : String(text);
|
|
1132
|
+
}
|
|
1133
|
+
if (s.kind.categoryY) return heatText(s)(value);
|
|
1134
|
+
if (s.kind.waterfall && !isTotal(s, row)) return signed(valueText(s.axis), value);
|
|
1135
|
+
if (share !== void 0) {
|
|
1136
|
+
const own = declaredAxes.value[s.axis]?.format;
|
|
1137
|
+
return own ? own(share) : percent.value.format(share);
|
|
1138
|
+
}
|
|
1139
|
+
return valueText(s.axis)(value);
|
|
1140
|
+
}
|
|
1141
|
+
/** Series with labels at the end of their line: the room they need past the plot, in px. */
|
|
1142
|
+
const endLabelRoom = computed(() => {
|
|
1143
|
+
if (pie.value || props.horizontal) return 0;
|
|
1144
|
+
let widest = 0;
|
|
1145
|
+
for (const part of seriesParts.value) {
|
|
1146
|
+
const p = part.props;
|
|
1147
|
+
if (!p.labels || p.labelPosition !== "end" || part.hidden.value) continue;
|
|
1148
|
+
const s = series.value.find((x) => x.props === part.props);
|
|
1149
|
+
if (!s) continue;
|
|
1150
|
+
for (let i = s.y.length - 1; i >= 0; i--) {
|
|
1151
|
+
const text = s.y[i] === s.y[i] ? labelText(s, i) : null;
|
|
1152
|
+
if (text === null) continue;
|
|
1153
|
+
widest = Math.max(widest, measure(text));
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return widest ? Math.ceil(widest) + 8 : 0;
|
|
1158
|
+
});
|
|
1159
|
+
/** The most of the chart's width a pie's labels outside may take, each side. */
|
|
1160
|
+
const pieLabelParts = computed(() => {
|
|
1161
|
+
const part = pie.value;
|
|
1162
|
+
const given = part?.props.labels;
|
|
1163
|
+
if (!part || !given) return [];
|
|
1164
|
+
return slices.value.map((slice) => {
|
|
1165
|
+
if (slice.hidden) return null;
|
|
1166
|
+
const share = percent.value.format(slice.share);
|
|
1167
|
+
if (typeof given !== "function") return {
|
|
1168
|
+
name: slice.label,
|
|
1169
|
+
share
|
|
1170
|
+
};
|
|
1171
|
+
const text = given({
|
|
1172
|
+
value: slice.value,
|
|
1173
|
+
share: slice.share,
|
|
1174
|
+
name: slice.label,
|
|
1175
|
+
row: rowOf(part.props.data ?? props.data, slice.index),
|
|
1176
|
+
index: slice.index
|
|
1177
|
+
});
|
|
1178
|
+
return text == null || text === "" ? null : {
|
|
1179
|
+
text: String(text),
|
|
1180
|
+
name: slice.label,
|
|
1181
|
+
share
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
const outsideText = (p) => p.text ?? `${p.name} ${p.share}`;
|
|
1186
|
+
/** Which side of the pie a slice's label goes, outside it. */
|
|
1187
|
+
const sideOf = (slice) => Math.sin((slice.start + slice.end) / 2) >= 0 ? "right" : "left";
|
|
1188
|
+
/** The widest label outside the pie on each side, at most a share of the chart's width. */
|
|
1189
|
+
const pieSideWidths = computed(() => {
|
|
1190
|
+
const widths = {
|
|
1191
|
+
left: 0,
|
|
1192
|
+
right: 0
|
|
1193
|
+
};
|
|
1194
|
+
const cap = size.width.value * PIE_LABEL_SHARE;
|
|
1195
|
+
slices.value.forEach((slice, k) => {
|
|
1196
|
+
const p = pieLabelParts.value[k];
|
|
1197
|
+
if (!p || !(slice.end > slice.start)) return;
|
|
1198
|
+
const side = sideOf(slice);
|
|
1199
|
+
widths[side] = Math.max(widths[side], Math.ceil(Math.min(cap, measure(outsideText(p)))));
|
|
1200
|
+
});
|
|
1201
|
+
return widths;
|
|
1202
|
+
});
|
|
1203
|
+
/**
|
|
1204
|
+
* Whether the pie's labels go outside it: as asked (the default), unless
|
|
1205
|
+
* the room they take would leave the pie too small; then inside, where
|
|
1206
|
+
* slices fit them.
|
|
1207
|
+
*/
|
|
1208
|
+
const pieLabelsOutside = computed(() => {
|
|
1209
|
+
const part = pie.value;
|
|
1210
|
+
if (!part?.props.labels || (part.props.labelPosition ?? "outside") !== "outside") return false;
|
|
1211
|
+
const width = size.width.value;
|
|
1212
|
+
const height = size.height.value;
|
|
1213
|
+
if (!width || !height) return true;
|
|
1214
|
+
themeVersion.value;
|
|
1215
|
+
const base = {
|
|
1216
|
+
width,
|
|
1217
|
+
height,
|
|
1218
|
+
measure,
|
|
1219
|
+
legend: legendNames.value,
|
|
1220
|
+
coarse: coarse.value,
|
|
1221
|
+
legendPlacement: legendPlacement.value,
|
|
1222
|
+
arc: pieArc.value
|
|
1223
|
+
};
|
|
1224
|
+
const alone = layoutPie(base).radius;
|
|
1225
|
+
return layoutPie({
|
|
1226
|
+
...base,
|
|
1227
|
+
sideLabels: pieSideWidths.value
|
|
1228
|
+
}).radius >= Math.min(alone * PIE_LEAST, PIE_LEAST_RADIUS);
|
|
1229
|
+
});
|
|
1230
|
+
/** Each slice's label text, by its place in `slices`; `null` for none. */
|
|
1231
|
+
const pieTexts = computed(() => {
|
|
1232
|
+
const outside = pieLabelsOutside.value;
|
|
1233
|
+
return pieLabelParts.value.map((p) => !p ? null : outside ? outsideText(p) : p.text ?? p.share);
|
|
1234
|
+
});
|
|
1235
|
+
/** A line from a slice out to its label, in px. */
|
|
1236
|
+
const pieLabels = computed(() => {
|
|
1237
|
+
const l = pieLayout.value;
|
|
1238
|
+
const texts = pieTexts.value;
|
|
1239
|
+
const out = {
|
|
1240
|
+
labels: [],
|
|
1241
|
+
leaders: []
|
|
1242
|
+
};
|
|
1243
|
+
if (!l || !texts.length || empty.value) return out;
|
|
1244
|
+
themeVersion.value;
|
|
1245
|
+
const { cx, cy, radius } = l;
|
|
1246
|
+
const list = slices.value;
|
|
1247
|
+
if (!pieLabelsOutside.value) {
|
|
1248
|
+
const inner = radius * holeRatio.value;
|
|
1249
|
+
list.forEach((slice, k) => {
|
|
1250
|
+
const text = texts[k];
|
|
1251
|
+
if (!text || !(slice.end > slice.start)) return;
|
|
1252
|
+
const outer = sliceOuter(slice, inner, radius);
|
|
1253
|
+
const at = inner > 0 ? (outer + inner) / 2 : outer * .62;
|
|
1254
|
+
const thickness = outer - inner;
|
|
1255
|
+
const width = measure(text);
|
|
1256
|
+
if ((slice.end - slice.start) * at < width + 8 || thickness < 20) return;
|
|
1257
|
+
const middle = (slice.start + slice.end) / 2;
|
|
1258
|
+
out.labels.push({
|
|
1259
|
+
key: `slice:${slice.index}`,
|
|
1260
|
+
series: -1,
|
|
1261
|
+
text,
|
|
1262
|
+
left: cx + Math.sin(middle) * at,
|
|
1263
|
+
top: cy - Math.cos(middle) * at - 8,
|
|
1264
|
+
align: "middle",
|
|
1265
|
+
on: sliceColors.value[k],
|
|
1266
|
+
ink: inkOn(sliceColors.value[k])
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
return out;
|
|
1270
|
+
}
|
|
1271
|
+
const widths = pieSideWidths.value;
|
|
1272
|
+
const room = {
|
|
1273
|
+
top: EDGE_ROOM,
|
|
1274
|
+
bottom: l.bottom + EDGE_ROOM
|
|
1275
|
+
};
|
|
1276
|
+
const sides = {
|
|
1277
|
+
left: [],
|
|
1278
|
+
right: []
|
|
1279
|
+
};
|
|
1280
|
+
list.forEach((slice, k) => {
|
|
1281
|
+
if (!texts[k] || !(slice.end > slice.start)) return;
|
|
1282
|
+
const middle = (slice.start + slice.end) / 2;
|
|
1283
|
+
const side = sideOf(slice);
|
|
1284
|
+
sides[side].push({
|
|
1285
|
+
k,
|
|
1286
|
+
y: cy - Math.cos(middle) * (radius + 10),
|
|
1287
|
+
middle
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
const capacity = Math.max(1, Math.floor((room.bottom - room.top) / 16));
|
|
1291
|
+
for (const side of ["left", "right"]) {
|
|
1292
|
+
let items = sides[side];
|
|
1293
|
+
if (items.length > capacity) {
|
|
1294
|
+
const kept = [...items];
|
|
1295
|
+
kept.sort((a, b) => list[b.k].share - list[a.k].share);
|
|
1296
|
+
const keep = new Set(kept.slice(0, capacity));
|
|
1297
|
+
items = items.filter((item) => keep.has(item));
|
|
1298
|
+
}
|
|
1299
|
+
const centers = spread(items.map((item) => item.y), 16, room.top, room.bottom);
|
|
1300
|
+
const right = side === "right";
|
|
1301
|
+
const widest = widths[side];
|
|
1302
|
+
const column = right ? cx + radius + 24 : cx - radius - 24;
|
|
1303
|
+
items.forEach((item, n) => {
|
|
1304
|
+
const y = centers[n];
|
|
1305
|
+
const sin = Math.sin(item.middle);
|
|
1306
|
+
const cos = Math.cos(item.middle);
|
|
1307
|
+
const end = column + (right ? -4 : 4);
|
|
1308
|
+
const clear = Math.sqrt(Math.max(0, (radius + 8) ** 2 - (y - cy) ** 2));
|
|
1309
|
+
const reach = Math.max(Math.abs(sin) * (radius + 10), clear);
|
|
1310
|
+
const turn = right ? Math.min(cx + reach, end) : Math.max(cx - reach, end);
|
|
1311
|
+
const hole = radius * holeRatio.value;
|
|
1312
|
+
const edge = sliceOuter(list[item.k], hole, radius);
|
|
1313
|
+
out.leaders.push([
|
|
1314
|
+
[cx + sin * (edge + 2), cy - cos * (edge + 2)],
|
|
1315
|
+
[turn, y],
|
|
1316
|
+
[end, y]
|
|
1317
|
+
]);
|
|
1318
|
+
const text = texts[item.k];
|
|
1319
|
+
const parts = pieLabelParts.value[item.k];
|
|
1320
|
+
const long = measure(text) > widest + .5;
|
|
1321
|
+
const fitted = long && parts.text === void 0 ? `${fitText(parts.name, widest - measure(` ${parts.share}`))} ${parts.share}` : text;
|
|
1322
|
+
const cut = long && fitted === text;
|
|
1323
|
+
out.labels.push({
|
|
1324
|
+
key: `slice:${list[item.k].index}`,
|
|
1325
|
+
series: -1,
|
|
1326
|
+
text: fitted,
|
|
1327
|
+
left: column,
|
|
1328
|
+
top: y - 8,
|
|
1329
|
+
align: right ? "start" : "end",
|
|
1330
|
+
width: cut ? widest : void 0
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
});
|
|
1336
|
+
/** Room kept clear at the chart's top and bottom edges, in px. */
|
|
1337
|
+
const labelsShown = ref(false);
|
|
1338
|
+
/** The data labels, placed: the ends of lines first, then the bars', then the points'. */
|
|
1339
|
+
const dataLabels = computed(() => {
|
|
1340
|
+
if (pie.value) return pieLabels.value.labels;
|
|
1341
|
+
if (tree.value) return treeLabels.value;
|
|
1342
|
+
const l = layout.value;
|
|
1343
|
+
if (!l || empty.value) return [];
|
|
1344
|
+
themeVersion.value;
|
|
1345
|
+
const { plot } = l;
|
|
1346
|
+
const bounds = {
|
|
1347
|
+
x: plot.x - 4,
|
|
1348
|
+
y: plot.y - 4,
|
|
1349
|
+
width: plot.width + 8,
|
|
1350
|
+
height: plot.height + 8
|
|
1351
|
+
};
|
|
1352
|
+
const ends = [];
|
|
1353
|
+
const bars = [];
|
|
1354
|
+
const points = [];
|
|
1355
|
+
for (const s of series.value) {
|
|
1356
|
+
const p = s.props;
|
|
1357
|
+
if (!p.labels || !s.kind.labels) continue;
|
|
1358
|
+
const view = viewOf(s);
|
|
1359
|
+
if (!view) continue;
|
|
1360
|
+
const lineWidth = p.lineWidth ?? 2;
|
|
1361
|
+
const offset = s.kind.band ? 0 : Math.max(2.5, lineWidth + .5);
|
|
1362
|
+
for (const anchor of s.kind.labels(s, view).slice(0, 1e3)) {
|
|
1363
|
+
const text = labelText(s, anchor.index);
|
|
1364
|
+
if (text === null) continue;
|
|
1365
|
+
const input = {
|
|
1366
|
+
key: `${s.index}:${anchor.index}`,
|
|
1367
|
+
series: s.index,
|
|
1368
|
+
text,
|
|
1369
|
+
anchor,
|
|
1370
|
+
offset
|
|
1371
|
+
};
|
|
1372
|
+
if (p.labelPosition === "end") ends.push({
|
|
1373
|
+
input,
|
|
1374
|
+
s
|
|
1375
|
+
});
|
|
1376
|
+
else if (s.kind.band) bars.push(input);
|
|
1377
|
+
else points.push(input);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const taken = [];
|
|
1381
|
+
const out = [];
|
|
1382
|
+
if (ends.length) {
|
|
1383
|
+
const centers = spread(ends.map((e) => e.input.anchor.at[1]), 16, plot.y - 4, plot.y + plot.height + 4);
|
|
1384
|
+
ends.forEach(({ input, s }, k) => {
|
|
1385
|
+
const left = input.anchor.at[0] + 4 + (input.offset ?? 0);
|
|
1386
|
+
const top = centers[k] - 8;
|
|
1387
|
+
taken.push([
|
|
1388
|
+
left,
|
|
1389
|
+
top,
|
|
1390
|
+
left + measure(input.text),
|
|
1391
|
+
top + 16
|
|
1392
|
+
]);
|
|
1393
|
+
out.push({
|
|
1394
|
+
key: input.key,
|
|
1395
|
+
series: s.index,
|
|
1396
|
+
text: input.text,
|
|
1397
|
+
left,
|
|
1398
|
+
top,
|
|
1399
|
+
align: "start",
|
|
1400
|
+
ink: s.color
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
for (const label of placeDataLabels(bars, bounds, measure, taken)) out.push({
|
|
1405
|
+
...label,
|
|
1406
|
+
ink: label.on && inkOn(label.on)
|
|
1407
|
+
});
|
|
1408
|
+
out.push(...placeDataLabels(points, bounds, measure, taken));
|
|
1409
|
+
return out;
|
|
1410
|
+
});
|
|
1411
|
+
/** Text that reads on a bar of `color`: the page's text color or its surface, whichever stands out more. */
|
|
1412
|
+
function inkOn(color) {
|
|
1413
|
+
const bar = luminance(resolveColor(color));
|
|
1414
|
+
const text = luminance(resolveColor("var(--dou-color-text)"));
|
|
1415
|
+
const surface = luminance(resolveColor("var(--dou-chart-surface)"));
|
|
1416
|
+
if (bar === null || text === null || surface === null) return "var(--dou-color-text)";
|
|
1417
|
+
return contrast(bar, text) >= contrast(bar, surface) ? "var(--dou-color-text)" : "var(--dou-chart-surface)";
|
|
1418
|
+
}
|
|
1419
|
+
const markerText = (tone) => tone === "neutral" ? "var(--dou-color-text-secondary)" : `var(--dou-color-${tone}-text)`;
|
|
1420
|
+
const markerColors = computed(() => {
|
|
1421
|
+
themeVersion.value;
|
|
1422
|
+
const surface = colors.value.surface;
|
|
1423
|
+
const of = (line, dot, fill) => ({
|
|
1424
|
+
line: resolveColor(line),
|
|
1425
|
+
dot: resolveColor(dot),
|
|
1426
|
+
fill: resolveColor(fill),
|
|
1427
|
+
surface
|
|
1428
|
+
});
|
|
1429
|
+
const tone = (name) => of(`var(--dou-color-${name})`, `var(--dou-color-${name})`, `color-mix(in oklab, var(--dou-color-${name}) 10%, transparent)`);
|
|
1430
|
+
return {
|
|
1431
|
+
neutral: of("var(--dou-chart-marker)", "var(--dou-color-text-secondary)", "color-mix(in oklab, var(--dou-color-text) 5%, transparent)"),
|
|
1432
|
+
primary: tone("primary"),
|
|
1433
|
+
success: tone("success"),
|
|
1434
|
+
warning: tone("warning"),
|
|
1435
|
+
danger: tone("danger")
|
|
1436
|
+
};
|
|
1437
|
+
});
|
|
1438
|
+
const tooltipPart = computed(() => tooltipParts.value[0] ?? null);
|
|
1439
|
+
/** Whether the chart follows the pointer and can be focused. */
|
|
1440
|
+
const interactive = computed(() => !tooltipPart.value?.props.disabled && !empty.value && !props.table && !pie.value?.gauge);
|
|
1441
|
+
const trigger = computed(() => tooltipPart.value?.props.trigger ?? (pie.value || tree.value || series.value.some((s) => s.kind.tooltip === "item") ? "item" : "axis"));
|
|
1442
|
+
/** An x (with every series at it), one point of a series, or a slice. */
|
|
1443
|
+
const active = shallowRef(null);
|
|
1444
|
+
/** What is under `(px, py)`, in px from the chart's top left. */
|
|
1445
|
+
function hitAt(px, py) {
|
|
1446
|
+
const tl = treeLayout.value;
|
|
1447
|
+
if (tl && treeData.value) {
|
|
1448
|
+
const root = treeData.value.root;
|
|
1449
|
+
let node;
|
|
1450
|
+
if (tree.value?.shape === "treemap") node = treemapAt(root, px, py);
|
|
1451
|
+
else {
|
|
1452
|
+
const r = Math.hypot(px - tl.cx, py - tl.cy);
|
|
1453
|
+
node = r < tl.inner || r > tl.radius ? null : sunburstAt(root, angleAt(tl.cx, tl.cy, px, py), r);
|
|
1454
|
+
}
|
|
1455
|
+
return node ? {
|
|
1456
|
+
type: "slice",
|
|
1457
|
+
slice: node.index
|
|
1458
|
+
} : null;
|
|
1459
|
+
}
|
|
1460
|
+
const p = pieLayout.value;
|
|
1461
|
+
if (p) {
|
|
1462
|
+
const r = Math.hypot(px - p.cx, py - p.cy);
|
|
1463
|
+
const inner = p.radius * holeRatio.value;
|
|
1464
|
+
if (r > p.radius || r < inner) return null;
|
|
1465
|
+
let angle = angleAt(p.cx, p.cy, px, py);
|
|
1466
|
+
while (angle < pieArc.value[0]) angle += Math.PI * 2;
|
|
1467
|
+
while (angle >= pieArc.value[0] + Math.PI * 2) angle -= Math.PI * 2;
|
|
1468
|
+
const k = slices.value.findIndex((s) => !s.hidden && angle >= s.start && angle < s.end && r <= sliceOuter(s, inner, p.radius));
|
|
1469
|
+
return k < 0 ? null : {
|
|
1470
|
+
type: "slice",
|
|
1471
|
+
slice: k
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
const f = funnelLayout.value;
|
|
1475
|
+
if (f) {
|
|
1476
|
+
const k = Math.floor((py - f.plot.y) / f.step);
|
|
1477
|
+
if (k < 0 || k >= f.count || funnelStages.value[k].row < 0) return null;
|
|
1478
|
+
return {
|
|
1479
|
+
type: "axis",
|
|
1480
|
+
x: k
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const r = radarLayout.value;
|
|
1484
|
+
if (r) {
|
|
1485
|
+
if (!r.count || Math.hypot(px - r.cx, py - r.cy) > r.radius + 16) return null;
|
|
1486
|
+
return {
|
|
1487
|
+
type: "axis",
|
|
1488
|
+
x: nearestSpoke(angleAt(r.cx, r.cy, px, py), r.count)
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const l = layout.value;
|
|
1492
|
+
if (!l) return null;
|
|
1493
|
+
const { plot } = l;
|
|
1494
|
+
const room = trigger.value === "item" ? 8 : 0;
|
|
1495
|
+
if (px < plot.x - room || px > plot.x + plot.width + room) return null;
|
|
1496
|
+
if (py < plot.y - room || py > plot.y + plot.height + room) return null;
|
|
1497
|
+
if (trigger.value === "axis") {
|
|
1498
|
+
const along = props.horizontal ? py : px;
|
|
1499
|
+
const x = nearestX(series.value.map((s) => s.x.values), l.x.scale, along);
|
|
1500
|
+
if (x === null) return null;
|
|
1501
|
+
const at = l.x.scale.map(x);
|
|
1502
|
+
const [r0, r1] = l.x.scale.range;
|
|
1503
|
+
return at < Math.min(r0, r1) - .5 || at > Math.max(r0, r1) + .5 ? null : {
|
|
1504
|
+
type: "axis",
|
|
1505
|
+
x
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
for (let k = series.value.length - 1; k >= 0; k--) {
|
|
1509
|
+
const s = series.value[k];
|
|
1510
|
+
const view = viewOf(s);
|
|
1511
|
+
if (!view) continue;
|
|
1512
|
+
const hit = s.kind.hit ? s.kind.hit(s, view, px, py) : nearestOf(s, view, px, py);
|
|
1513
|
+
if (!hit) continue;
|
|
1514
|
+
const [left, top] = view.point(s.x.values[hit.index], s.top[hit.index]);
|
|
1515
|
+
if (left < plot.x - 4 || left > plot.x + plot.width + 4) continue;
|
|
1516
|
+
if (top < plot.y - 4 || top > plot.y + plot.height + 4) continue;
|
|
1517
|
+
return {
|
|
1518
|
+
type: "item",
|
|
1519
|
+
series: s.index,
|
|
1520
|
+
row: hit.index,
|
|
1521
|
+
radius: hit.radius
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
/** How near a line the pointer comes before its series is brought forward, in px. */
|
|
1527
|
+
const pointed = computed(() => {
|
|
1528
|
+
const a = active.value;
|
|
1529
|
+
if (!a?.pointer || pie.value || radar.value || funnel.value || series.value.length < 2) return null;
|
|
1530
|
+
if (a.type === "item") return a.series;
|
|
1531
|
+
if (a.type !== "axis") return null;
|
|
1532
|
+
const along = alongX(a.pointer);
|
|
1533
|
+
const value = alongY(a.pointer);
|
|
1534
|
+
let near = null;
|
|
1535
|
+
let over = null;
|
|
1536
|
+
for (const s of series.value) {
|
|
1537
|
+
const row = rowAt(s.x.values, a.x);
|
|
1538
|
+
const view = row < 0 ? null : viewOf(s);
|
|
1539
|
+
const top = view ? s.top[row] : NaN;
|
|
1540
|
+
if (!view || top !== top) continue;
|
|
1541
|
+
const end = alongY(view.point(a.x, top));
|
|
1542
|
+
if (s.kind.band) {
|
|
1543
|
+
const { offset, width } = barPlacement(view.band, s.slot);
|
|
1544
|
+
const u = view.x.map(a.x) + offset;
|
|
1545
|
+
const start = s.base ? view.y.map(s.base[row]) : baseline(view.y);
|
|
1546
|
+
if (along >= u && along <= u + width && value >= Math.min(start, end) && value <= Math.max(start, end)) over = s.index;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
const distance = Math.abs(value - end);
|
|
1550
|
+
if (distance <= NEAR_LINE && (!near || distance < near.distance)) near = {
|
|
1551
|
+
index: s.index,
|
|
1552
|
+
distance
|
|
1553
|
+
};
|
|
1554
|
+
if (s.kind.zero && !s.kind.band) {
|
|
1555
|
+
const start = s.base ? view.y.map(s.base[row]) : baseline(view.y);
|
|
1556
|
+
if (value >= Math.min(start, end) && value <= Math.max(start, end)) over = s.index;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return near?.index ?? over;
|
|
1560
|
+
});
|
|
1561
|
+
/** The point of `s` nearest to `(px, py)`, within 12px. */
|
|
1562
|
+
function nearestOf(s, view, px, py) {
|
|
1563
|
+
const xs = s.x.values;
|
|
1564
|
+
const hit = nearestPoint(Math.min(xs.length, s.top.length), (i) => xs[i] === xs[i] && s.top[i] === s.top[i] ? view.point(xs[i], s.top[i]) : null, px, py, () => 12);
|
|
1565
|
+
return hit && {
|
|
1566
|
+
index: hit.index,
|
|
1567
|
+
radius: 4
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const percent = computed(() => new Intl.NumberFormat(locale.value.name, {
|
|
1571
|
+
style: "percent",
|
|
1572
|
+
maximumFractionDigits: 1
|
|
1573
|
+
}));
|
|
1574
|
+
/** A funnel's rates: always one decimal, so a column of them lines up. */
|
|
1575
|
+
const rate = computed(() => new Intl.NumberFormat(locale.value.name, {
|
|
1576
|
+
style: "percent",
|
|
1577
|
+
minimumFractionDigits: 1,
|
|
1578
|
+
maximumFractionDigits: 1
|
|
1579
|
+
}));
|
|
1580
|
+
/** What is shown for the active x, point or slice. */
|
|
1581
|
+
const tip = computed(() => {
|
|
1582
|
+
if (!interactive.value) return null;
|
|
1583
|
+
const t = tipOf(active.value);
|
|
1584
|
+
return t && active.value?.touch ? {
|
|
1585
|
+
...t,
|
|
1586
|
+
above: true
|
|
1587
|
+
} : t;
|
|
1588
|
+
});
|
|
1589
|
+
function tipOf(a) {
|
|
1590
|
+
if (!a) return null;
|
|
1591
|
+
if (a.type === "slice" && tree.value) return treeTip(a.slice, a.pointer);
|
|
1592
|
+
if (a.type === "slice") {
|
|
1593
|
+
const p = pieLayout.value;
|
|
1594
|
+
const slice = slices.value[a.slice];
|
|
1595
|
+
if (!p || !slice || slice.hidden) return null;
|
|
1596
|
+
const middle = (slice.start + slice.end) / 2;
|
|
1597
|
+
const inner = p.radius * holeRatio.value;
|
|
1598
|
+
const outer = sliceOuter(slice, inner, p.radius);
|
|
1599
|
+
const at = inner > 0 ? (inner + outer) / 2 : outer * .6;
|
|
1600
|
+
const off = slices.value.length - shownSlices.value.length;
|
|
1601
|
+
return {
|
|
1602
|
+
x: slice.label,
|
|
1603
|
+
note: off ? format(messages.value.hiddenSlices, { count: off }) : void 0,
|
|
1604
|
+
items: [{
|
|
1605
|
+
name: slice.label,
|
|
1606
|
+
text: format(messages.value.share, {
|
|
1607
|
+
value: numberFormatFull(locale.value.name)(slice.value),
|
|
1608
|
+
share: percent.value.format(slice.share)
|
|
1609
|
+
}),
|
|
1610
|
+
value: slice.value,
|
|
1611
|
+
color: sliceColors.value[a.slice],
|
|
1612
|
+
row: rowOf(pie.value.props.data ?? props.data, slice.index)
|
|
1613
|
+
}],
|
|
1614
|
+
anchor: a.pointer ?? [p.cx + Math.sin(middle) * at, p.cy - Math.cos(middle) * at],
|
|
1615
|
+
marks: []
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
if (a.type === "axis" && radarLayout.value) return radarTip(radarLayout.value, a);
|
|
1619
|
+
if (a.type === "axis" && funnelLayout.value) return funnelTip(funnelLayout.value, a);
|
|
1620
|
+
const l = layout.value;
|
|
1621
|
+
if (!l) return null;
|
|
1622
|
+
const { plot } = l;
|
|
1623
|
+
const horizontal = !!props.horizontal;
|
|
1624
|
+
if (a.type === "item") {
|
|
1625
|
+
const s = series.value.find((x) => x.index === a.series);
|
|
1626
|
+
const view = s && viewOf(s);
|
|
1627
|
+
if (!s || !view) return null;
|
|
1628
|
+
const x = s.x.values[a.row];
|
|
1629
|
+
const y = s.y[a.row];
|
|
1630
|
+
if (x !== x || y !== y) return null;
|
|
1631
|
+
const [left, top] = view.point(x, s.top[a.row]);
|
|
1632
|
+
if (s.kind.categoryY) return heatTip(s, view, a.row, x, left, top);
|
|
1633
|
+
const items = [{
|
|
1634
|
+
name: axes.value.x.title ?? "x",
|
|
1635
|
+
text: xText(x),
|
|
1636
|
+
value: m0x() ? x : void 0
|
|
1637
|
+
}, {
|
|
1638
|
+
name: axes.value[s.axis]?.title ?? (s.axis === "y2" ? "y2" : "y"),
|
|
1639
|
+
text: seriesText(s, a.row),
|
|
1640
|
+
value: y,
|
|
1641
|
+
color: s.colors?.[a.row] ?? s.color
|
|
1642
|
+
}];
|
|
1643
|
+
const sizeOf = s.props.size;
|
|
1644
|
+
if (sizeOf !== void 0) {
|
|
1645
|
+
const value = Number(read(rowOf(s.rows, a.row), sizeOf, a.row));
|
|
1646
|
+
const name = s.props.sizeName ?? (typeof sizeOf === "string" ? sizeOf : "");
|
|
1647
|
+
if (value === value) items.push({
|
|
1648
|
+
name,
|
|
1649
|
+
text: numberFormatFull(locale.value.name)(value),
|
|
1650
|
+
value
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
for (const item of items) item.row = rowOf(s.rows, a.row);
|
|
1654
|
+
const label = s.props.label;
|
|
1655
|
+
const own = label === void 0 ? null : read(rowOf(s.rows, a.row), label, a.row);
|
|
1656
|
+
return {
|
|
1657
|
+
x: rawX(x),
|
|
1658
|
+
title: own == null || own === "" ? s.name : String(own),
|
|
1659
|
+
items,
|
|
1660
|
+
anchor: [left, top],
|
|
1661
|
+
marks: [{
|
|
1662
|
+
left,
|
|
1663
|
+
top,
|
|
1664
|
+
color: s.colors?.[a.row] ?? s.color,
|
|
1665
|
+
radius: a.radius + 3,
|
|
1666
|
+
ring: true
|
|
1667
|
+
}]
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const x = a.x;
|
|
1671
|
+
const at = l.x.scale.map(x);
|
|
1672
|
+
const items = [];
|
|
1673
|
+
/** The series under the pointer, brought forward. */
|
|
1674
|
+
const under = pointed.value;
|
|
1675
|
+
/** The series of each item. */
|
|
1676
|
+
const owners = [];
|
|
1677
|
+
const marks = [];
|
|
1678
|
+
let band = false;
|
|
1679
|
+
/** A title of a series' own (a histogram's bin), in place of the x. */
|
|
1680
|
+
let title;
|
|
1681
|
+
for (const s of series.value) {
|
|
1682
|
+
const row = rowAt(s.x.values, x);
|
|
1683
|
+
const view = viewOf(s);
|
|
1684
|
+
if (row < 0 || !view) continue;
|
|
1685
|
+
const y = s.y[row];
|
|
1686
|
+
if (y !== y) continue;
|
|
1687
|
+
title ??= s.kind.xTitle?.(s, row, xText);
|
|
1688
|
+
if (s.kind.box) {
|
|
1689
|
+
const own = boxItems(s, row);
|
|
1690
|
+
items.push(...own);
|
|
1691
|
+
for (const _ of own) owners.push(s.index);
|
|
1692
|
+
band = true;
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
items.push({
|
|
1696
|
+
name: s.name,
|
|
1697
|
+
text: seriesText(s, row),
|
|
1698
|
+
value: y,
|
|
1699
|
+
color: s.colors?.[row] ?? s.color,
|
|
1700
|
+
row: rowOf(s.rows, row)
|
|
1701
|
+
});
|
|
1702
|
+
owners.push(s.index);
|
|
1703
|
+
if (s.kind.band) band = true;
|
|
1704
|
+
else {
|
|
1705
|
+
const [left, top] = view.point(x, s.top[row]);
|
|
1706
|
+
marks.push({
|
|
1707
|
+
left,
|
|
1708
|
+
top,
|
|
1709
|
+
color: s.colors?.[row] ?? s.color,
|
|
1710
|
+
radius: 4,
|
|
1711
|
+
ring: false,
|
|
1712
|
+
faded: under !== null && s.index !== under
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (!items.length) return null;
|
|
1717
|
+
if (under !== null && items.length > 1) {
|
|
1718
|
+
for (let k = 0; k < owners.length; k++) if (owners[k] === under) items[k].active = true;
|
|
1719
|
+
}
|
|
1720
|
+
const crosshair = tooltipPart.value?.props.crosshair ?? true;
|
|
1721
|
+
const room = band ? viewOf(series.value[0])?.band ?? 0 : 1;
|
|
1722
|
+
const across = (start, length) => horizontal ? {
|
|
1723
|
+
x: plot.x,
|
|
1724
|
+
y: start,
|
|
1725
|
+
width: plot.width,
|
|
1726
|
+
height: length
|
|
1727
|
+
} : {
|
|
1728
|
+
x: start,
|
|
1729
|
+
y: plot.y,
|
|
1730
|
+
width: length,
|
|
1731
|
+
height: plot.height
|
|
1732
|
+
};
|
|
1733
|
+
const dpr = dprNow();
|
|
1734
|
+
const line = crisp(at, dpr) - .5;
|
|
1735
|
+
let anchor = a.pointer;
|
|
1736
|
+
if (!anchor) {
|
|
1737
|
+
if (horizontal) anchor = [marks.length ? Math.max(...marks.map((m) => m.left)) : plot.x + plot.width / 2, at];
|
|
1738
|
+
else anchor = [at, marks.length ? Math.min(...marks.map((m) => m.top)) : plot.y + plot.height / 2];
|
|
1739
|
+
}
|
|
1740
|
+
const width = band ? room : 24;
|
|
1741
|
+
return {
|
|
1742
|
+
x: rawX(x),
|
|
1743
|
+
title: title ?? xText(x),
|
|
1744
|
+
items,
|
|
1745
|
+
focused: items.some((item) => item.active),
|
|
1746
|
+
anchor,
|
|
1747
|
+
clear: a.touch ? across(at - width / 2, width) : void 0,
|
|
1748
|
+
marks,
|
|
1749
|
+
band: crosshair && band ? across(at - room / 2, room) : void 0,
|
|
1750
|
+
line: crosshair && !band ? across(line, 1) : void 0
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
/** A funnel stage's tooltip: its value, and what it kept of the stage before and of the first; its row shaded. */
|
|
1754
|
+
function funnelTip(f, a) {
|
|
1755
|
+
const s = funnelSeries.value;
|
|
1756
|
+
const stage = funnelStages.value[a.x];
|
|
1757
|
+
if (!s || !stage || stage.row < 0) return null;
|
|
1758
|
+
const m = messages.value;
|
|
1759
|
+
const row = rowOf(s.rows, stage.row);
|
|
1760
|
+
const items = [{
|
|
1761
|
+
name: s.name,
|
|
1762
|
+
text: seriesText(s, stage.row),
|
|
1763
|
+
value: stage.value,
|
|
1764
|
+
color: s.color,
|
|
1765
|
+
row
|
|
1766
|
+
}];
|
|
1767
|
+
if (stage.step !== null) items.push({
|
|
1768
|
+
name: m.stageRate,
|
|
1769
|
+
text: rate.value.format(stage.step),
|
|
1770
|
+
value: stage.step,
|
|
1771
|
+
row
|
|
1772
|
+
});
|
|
1773
|
+
if (stage.total !== null && a.x > 0) items.push({
|
|
1774
|
+
name: m.totalRate,
|
|
1775
|
+
text: rate.value.format(stage.total),
|
|
1776
|
+
value: stage.total,
|
|
1777
|
+
row
|
|
1778
|
+
});
|
|
1779
|
+
const top = f.plot.y + f.step * a.x;
|
|
1780
|
+
const texts = [
|
|
1781
|
+
...f.names,
|
|
1782
|
+
...f.values,
|
|
1783
|
+
...f.rates
|
|
1784
|
+
];
|
|
1785
|
+
const from = Math.min(f.plot.x, ...texts.map((t) => t.left)) - 4;
|
|
1786
|
+
const to = Math.max(f.plot.x + f.plot.width, ...texts.map((t) => t.left + t.width)) + 4;
|
|
1787
|
+
const [right, middle] = funnelView(f, dprNow(), false).point(a.x, stage.value);
|
|
1788
|
+
return {
|
|
1789
|
+
x: rawX(a.x),
|
|
1790
|
+
title: xText(a.x),
|
|
1791
|
+
items,
|
|
1792
|
+
anchor: a.pointer ?? [right, middle],
|
|
1793
|
+
marks: [],
|
|
1794
|
+
band: {
|
|
1795
|
+
x: from,
|
|
1796
|
+
y: top,
|
|
1797
|
+
width: to - from,
|
|
1798
|
+
height: f.step
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/** A radar's tooltip: every series along one spoke, a dot on each, beside the outermost. */
|
|
1803
|
+
function radarTip(r, a) {
|
|
1804
|
+
const x = a.x;
|
|
1805
|
+
const items = [];
|
|
1806
|
+
const marks = [];
|
|
1807
|
+
const view = radarView(r, dprNow(), false);
|
|
1808
|
+
let far = null;
|
|
1809
|
+
let farthest = -1;
|
|
1810
|
+
for (const s of series.value) {
|
|
1811
|
+
const row = rowAt(s.x.values, x);
|
|
1812
|
+
if (row < 0) continue;
|
|
1813
|
+
const y = s.y[row];
|
|
1814
|
+
if (y !== y) continue;
|
|
1815
|
+
items.push({
|
|
1816
|
+
name: s.name,
|
|
1817
|
+
text: seriesText(s, row),
|
|
1818
|
+
value: y,
|
|
1819
|
+
color: s.color,
|
|
1820
|
+
row: rowOf(s.rows, row)
|
|
1821
|
+
});
|
|
1822
|
+
const [left, top] = view.point(x, s.top[row]);
|
|
1823
|
+
marks.push({
|
|
1824
|
+
left,
|
|
1825
|
+
top,
|
|
1826
|
+
color: s.color,
|
|
1827
|
+
radius: 4,
|
|
1828
|
+
ring: false
|
|
1829
|
+
});
|
|
1830
|
+
const out = Math.hypot(left - r.cx, top - r.cy);
|
|
1831
|
+
if (out > farthest) {
|
|
1832
|
+
farthest = out;
|
|
1833
|
+
far = [left, top];
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
if (!items.length) return null;
|
|
1837
|
+
return {
|
|
1838
|
+
x: rawX(x),
|
|
1839
|
+
title: xText(x),
|
|
1840
|
+
items,
|
|
1841
|
+
anchor: a.pointer ?? far ?? [r.cx, r.cy],
|
|
1842
|
+
marks
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* A box plot's numbers in the tooltip, top to bottom as drawn, and how many
|
|
1847
|
+
* values are past the whiskers. Beside other series, each says whose it is.
|
|
1848
|
+
*/
|
|
1849
|
+
function boxItems(s, row) {
|
|
1850
|
+
const box = boxes(s)[row];
|
|
1851
|
+
if (!box) return [];
|
|
1852
|
+
const m = messages.value;
|
|
1853
|
+
const text = valueText(s.axis);
|
|
1854
|
+
const own = series.value.length > 1 ? `${s.name} ` : "";
|
|
1855
|
+
const data = rowOf(s.rows, row);
|
|
1856
|
+
const items = [
|
|
1857
|
+
[m.boxHigh, box.high],
|
|
1858
|
+
[m.boxQ3, box.q3],
|
|
1859
|
+
[m.boxMedian, box.median],
|
|
1860
|
+
[m.boxQ1, box.q1],
|
|
1861
|
+
[m.boxLow, box.low]
|
|
1862
|
+
].map(([name, value]) => ({
|
|
1863
|
+
name: own + name,
|
|
1864
|
+
text: text(value),
|
|
1865
|
+
value,
|
|
1866
|
+
row: data
|
|
1867
|
+
}));
|
|
1868
|
+
items[2].color = s.color;
|
|
1869
|
+
if (box.outliers.length) items.push({
|
|
1870
|
+
name: own + format(m.boxOutliers, { count: box.outliers.length }),
|
|
1871
|
+
text: box.outliers.slice(0, 6).map(text).join(m.nameSeparator) + (box.outliers.length > 6 ? "…" : ""),
|
|
1872
|
+
row: data
|
|
1873
|
+
});
|
|
1874
|
+
return items;
|
|
1875
|
+
}
|
|
1876
|
+
/** A heatmap cell's tooltip: where it is (its x and row) as the title, its value in its color, the cell ringed. */
|
|
1877
|
+
function heatTip(s, view, row, x, left, top) {
|
|
1878
|
+
const value = s.y[row];
|
|
1879
|
+
const range = heatRange(s);
|
|
1880
|
+
const color = range ? heatColor(s.color, colors.value.surface, heatShare(value, range)) : s.color;
|
|
1881
|
+
const item = {
|
|
1882
|
+
name: s.name,
|
|
1883
|
+
text: heatText(s)(value),
|
|
1884
|
+
value,
|
|
1885
|
+
color
|
|
1886
|
+
};
|
|
1887
|
+
item.row = rowOf(s.rows, row);
|
|
1888
|
+
const across = view.band;
|
|
1889
|
+
const down = Math.abs(view.y.map(0) - view.y.map(1));
|
|
1890
|
+
const [width, height] = view.horizontal ? [down, across] : [across, down];
|
|
1891
|
+
const ring = 1;
|
|
1892
|
+
return {
|
|
1893
|
+
x: rawX(x),
|
|
1894
|
+
title: `${yCategoryText(s.axis, s.top[row])}${messages.value.itemSeparator}${xText(x)}`,
|
|
1895
|
+
items: [item],
|
|
1896
|
+
anchor: [left + width / 2, top],
|
|
1897
|
+
marks: [],
|
|
1898
|
+
cell: {
|
|
1899
|
+
x: left - width / 2 - ring,
|
|
1900
|
+
y: top - height / 2 - ring,
|
|
1901
|
+
width: width + 2 - 1,
|
|
1902
|
+
height: height + 2 - 1
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
/** An x as given: the category, a `Date` on a time axis, or the number. */
|
|
1907
|
+
function rawX(x) {
|
|
1908
|
+
const kind = model.value.xKind;
|
|
1909
|
+
return kind === "category" ? model.value.categories[x] : kind === "time" ? new Date(x) : x;
|
|
1910
|
+
}
|
|
1911
|
+
/** What `select` and `hover` give: what the tooltip shows there. */
|
|
1912
|
+
const eventOf = (t) => ({
|
|
1913
|
+
x: t.x,
|
|
1914
|
+
title: t.title,
|
|
1915
|
+
items: t.items
|
|
1916
|
+
});
|
|
1917
|
+
/** Tells the page what was picked: what the tooltip shows there. */
|
|
1918
|
+
function select(t) {
|
|
1919
|
+
emit("select", eventOf(t));
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* What `hover` tells: what the tooltip shows for this chart's own pointer,
|
|
1923
|
+
* finger or keys. An x shown because another chart of the group points at it
|
|
1924
|
+
* is not hovered here. Worked out only when someone listens; it follows the
|
|
1925
|
+
* data too, so a hovered bar whose data is replaced or changed is told again.
|
|
1926
|
+
*/
|
|
1927
|
+
const hovering = computed(() => {
|
|
1928
|
+
if (!instance?.vnode.props?.onHover) return null;
|
|
1929
|
+
const a = active.value;
|
|
1930
|
+
if (!a || a.synced || !interactive.value) return null;
|
|
1931
|
+
const t = tipOf(a);
|
|
1932
|
+
return t && eventOf(t);
|
|
1933
|
+
});
|
|
1934
|
+
/** The same value: a `Date` by its time. */
|
|
1935
|
+
const sameValue = (a, b) => Object.is(a, b) || a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
1936
|
+
/** Whether two `hover` events say the same: so a pointer moving over one bar tells it once. */
|
|
1937
|
+
function sameHover(a, b) {
|
|
1938
|
+
if (!a || !b) return a === b;
|
|
1939
|
+
return sameValue(a.x, b.x) && a.title === b.title && a.items.length === b.items.length && a.items.every((item, i) => item.name === b.items[i].name && item.text === b.items[i].text && item.row === b.items[i].row && sameValue(item.value, b.items[i].value));
|
|
1940
|
+
}
|
|
1941
|
+
/** What the page was last told, so it hears each change once. */
|
|
1942
|
+
let hoverTold = null;
|
|
1943
|
+
watch(hovering, (next) => {
|
|
1944
|
+
if (sameHover(next, hoverTold)) return;
|
|
1945
|
+
hoverTold = next;
|
|
1946
|
+
emit("hover", next);
|
|
1947
|
+
});
|
|
1948
|
+
onBeforeUnmount(() => {
|
|
1949
|
+
if (hoverTold) emit("hover", null);
|
|
1950
|
+
});
|
|
1951
|
+
/** Whether anyone listens to `select`: then what can be picked shows a pointing hand. */
|
|
1952
|
+
const selectable = () => !!instance?.vnode.props?.onSelect;
|
|
1953
|
+
function onClick(event) {
|
|
1954
|
+
if (event.target.closest?.("button")) return;
|
|
1955
|
+
if (dragged) {
|
|
1956
|
+
dragged = false;
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const at = local(event);
|
|
1960
|
+
const kept = selection.value;
|
|
1961
|
+
if (kept && inPlot(at) && !inRect(at, kept)) clearBrush();
|
|
1962
|
+
const target = hitAt(...at);
|
|
1963
|
+
const t = target && tipOf({
|
|
1964
|
+
...target,
|
|
1965
|
+
pointer: at
|
|
1966
|
+
});
|
|
1967
|
+
if (t) select(t);
|
|
1968
|
+
}
|
|
1969
|
+
watch(active, (a) => {
|
|
1970
|
+
const root = rootRef.value;
|
|
1971
|
+
if (root && selectable()) root.style.cursor = a?.pointer && !a.touch ? "pointer" : "";
|
|
1972
|
+
});
|
|
1973
|
+
/** Whether x is a number worth giving as `value` (not a category's index). */
|
|
1974
|
+
const m0x = () => model.value.xKind !== "category";
|
|
1975
|
+
function setActive(next, pointer, touch = false) {
|
|
1976
|
+
const a = active.value;
|
|
1977
|
+
if (!next) {
|
|
1978
|
+
if (a) {
|
|
1979
|
+
active.value = null;
|
|
1980
|
+
if (!a.synced) tellX(null);
|
|
1981
|
+
}
|
|
1982
|
+
zoomNote.value = "";
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
zoomNote.value = "";
|
|
1986
|
+
active.value = {
|
|
1987
|
+
...next,
|
|
1988
|
+
pointer,
|
|
1989
|
+
touch
|
|
1990
|
+
};
|
|
1991
|
+
if (next.type === "axis" && (a?.type !== "axis" || a.x !== next.x || a.synced)) tellX(next.x);
|
|
1992
|
+
}
|
|
1993
|
+
const group = computed(() => tooltipPart.value?.props.group);
|
|
1994
|
+
/** The x of this chart as the group shares it: a category's name, else its number. */
|
|
1995
|
+
function sharedX(x) {
|
|
1996
|
+
return model.value.xKind === "category" ? model.value.categories[x] ?? null : x;
|
|
1997
|
+
}
|
|
1998
|
+
/** Tells the group this chart's x, or that it shows none. */
|
|
1999
|
+
function tellX(x) {
|
|
2000
|
+
const name = group.value;
|
|
2001
|
+
if (name) tellGroup(name, x === null ? null : sharedX(x), follow);
|
|
2002
|
+
}
|
|
2003
|
+
/** Shows the x another chart of the group points at, if this chart has it. */
|
|
2004
|
+
function follow(x) {
|
|
2005
|
+
const a = active.value;
|
|
2006
|
+
if (a && !a.synced) return;
|
|
2007
|
+
const mine = x === null || !interactive.value || trigger.value !== "axis" ? null : localX(x);
|
|
2008
|
+
if (mine === null) {
|
|
2009
|
+
if (a) active.value = null;
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
active.value = {
|
|
2013
|
+
type: "axis",
|
|
2014
|
+
x: mine,
|
|
2015
|
+
pointer: null,
|
|
2016
|
+
synced: true
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
/** The x of this chart that `x` from the group names: the same category, or the same number. */
|
|
2020
|
+
function localX(x) {
|
|
2021
|
+
const m = model.value;
|
|
2022
|
+
if (m.xKind === "category") {
|
|
2023
|
+
const k = typeof x === "string" ? m.categories.indexOf(x) : -1;
|
|
2024
|
+
return k < 0 ? null : k;
|
|
2025
|
+
}
|
|
2026
|
+
if (typeof x !== "number") return null;
|
|
2027
|
+
for (const s of series.value) if (rowAt(s.x.values, x) >= 0) return x;
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
watch(group, (name, _, onCleanup) => {
|
|
2031
|
+
if (name) onCleanup(joinGroup(name, follow));
|
|
2032
|
+
}, { immediate: true });
|
|
2033
|
+
let pointerFrame = 0;
|
|
2034
|
+
let lastPointer = null;
|
|
2035
|
+
function local(event) {
|
|
2036
|
+
const rect = rootRef.value.getBoundingClientRect();
|
|
2037
|
+
return [event.clientX - rect.left, event.clientY - rect.top];
|
|
2038
|
+
}
|
|
2039
|
+
function onPointerMove(event) {
|
|
2040
|
+
if (gesture(event)) return;
|
|
2041
|
+
if (!interactive.value || event.pointerType === "touch") return;
|
|
2042
|
+
lastPointer = local(event);
|
|
2043
|
+
if (pointerFrame || typeof requestAnimationFrame === "undefined") {
|
|
2044
|
+
if (!pointerFrame) followPointer();
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
pointerFrame = requestAnimationFrame(() => {
|
|
2048
|
+
pointerFrame = 0;
|
|
2049
|
+
followPointer();
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
function followPointer() {
|
|
2053
|
+
if (!lastPointer || drag?.moved) return;
|
|
2054
|
+
const [x, y] = lastPointer;
|
|
2055
|
+
setActive(hitAt(x, y), lastPointer);
|
|
2056
|
+
}
|
|
2057
|
+
function onPointerLeave(event) {
|
|
2058
|
+
if (event.pointerType === "touch" || drag?.moved) return;
|
|
2059
|
+
lastPointer = null;
|
|
2060
|
+
if (active.value?.pointer) setActive(null, null);
|
|
2061
|
+
}
|
|
2062
|
+
/** A press on the plot that may turn into a pan or a selection. */
|
|
2063
|
+
let drag = null;
|
|
2064
|
+
/** Two fingers on the plot: they zoom about the point between them. */
|
|
2065
|
+
let pinch = null;
|
|
2066
|
+
const fingers = /* @__PURE__ */ new Map();
|
|
2067
|
+
/** The selection being dragged, in px. */
|
|
2068
|
+
const brushDraft = shallowRef(null);
|
|
2069
|
+
/** A drag just ended: the click that follows it isn't one. */
|
|
2070
|
+
let dragged = false;
|
|
2071
|
+
/** How far a press moves before it is a drag, and how long a finger is held to select. */
|
|
2072
|
+
const SLOP = {
|
|
2073
|
+
mouse: 3,
|
|
2074
|
+
touch: 8
|
|
2075
|
+
};
|
|
2076
|
+
function inPlot([px, py]) {
|
|
2077
|
+
const plot = layout.value?.plot;
|
|
2078
|
+
return !!plot && px >= plot.x && px <= plot.x + plot.width && py >= plot.y && py <= plot.y + plot.height;
|
|
2079
|
+
}
|
|
2080
|
+
let tapped = false;
|
|
2081
|
+
function onPointerDown(event) {
|
|
2082
|
+
const touch = event.pointerType === "touch";
|
|
2083
|
+
tapped = touch && interactive.value;
|
|
2084
|
+
if (event.button !== 0 || event.target.closest?.("button")) return;
|
|
2085
|
+
const at = local(event);
|
|
2086
|
+
const from = scalesNow();
|
|
2087
|
+
if (!from || !inPlot(at) || !(zoomOn.value || brushPart.value)) return;
|
|
2088
|
+
if (touch) {
|
|
2089
|
+
fingers.set(event.pointerId, at);
|
|
2090
|
+
if (fingers.size === 2 && zoomOn.value) {
|
|
2091
|
+
startPinch(from);
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (fingers.size > 1) return;
|
|
2095
|
+
}
|
|
2096
|
+
const brush = !!brushPart.value;
|
|
2097
|
+
const { sideways, upDown } = panDirections();
|
|
2098
|
+
const mode = touch ? sideways || upDown ? "pan" : null : brush && (event.shiftKey || !zoomOn.value) ? "brush" : zoomOn.value ? "pan" : null;
|
|
2099
|
+
if (!mode && !(touch && brush)) return;
|
|
2100
|
+
const hold = holdPointer(event, rootRef.value);
|
|
2101
|
+
drag = {
|
|
2102
|
+
id: event.pointerId,
|
|
2103
|
+
touch,
|
|
2104
|
+
start: at,
|
|
2105
|
+
mode,
|
|
2106
|
+
moved: false,
|
|
2107
|
+
from,
|
|
2108
|
+
hold,
|
|
2109
|
+
timer: void 0
|
|
2110
|
+
};
|
|
2111
|
+
if (touch && brush) drag.timer = setTimeout(() => {
|
|
2112
|
+
if (!drag || drag.moved) return;
|
|
2113
|
+
drag.mode = "brush";
|
|
2114
|
+
startDrag();
|
|
2115
|
+
brushDraft.value = brushRect(drag.start, drag.start);
|
|
2116
|
+
}, HOLD);
|
|
2117
|
+
}
|
|
2118
|
+
/** The press became a drag: it keeps the pointer, and the tooltip goes. */
|
|
2119
|
+
function startDrag() {
|
|
2120
|
+
if (!drag) return;
|
|
2121
|
+
drag.moved = true;
|
|
2122
|
+
tapped = false;
|
|
2123
|
+
clearTimeout(drag.timer);
|
|
2124
|
+
drag.hold.grab();
|
|
2125
|
+
if (drag.mode === "pan" && rootRef.value) rootRef.value.style.cursor = "grabbing";
|
|
2126
|
+
if (!drag.touch) try {
|
|
2127
|
+
rootRef.value?.setPointerCapture(drag.id);
|
|
2128
|
+
} catch {}
|
|
2129
|
+
setActive(null, null);
|
|
2130
|
+
}
|
|
2131
|
+
function startPinch(from) {
|
|
2132
|
+
endDrag(false);
|
|
2133
|
+
tapped = false;
|
|
2134
|
+
const [a, b] = [...fingers.values()];
|
|
2135
|
+
pinch = {
|
|
2136
|
+
distance: Math.max(1, Math.hypot(a[0] - b[0], a[1] - b[1])),
|
|
2137
|
+
middle: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
|
2138
|
+
from
|
|
2139
|
+
};
|
|
2140
|
+
setActive(null, null);
|
|
2141
|
+
}
|
|
2142
|
+
/** Follows a drag or a pinch; `true` when the move was one. */
|
|
2143
|
+
function gesture(event) {
|
|
2144
|
+
if (pinch && fingers.has(event.pointerId)) {
|
|
2145
|
+
fingers.set(event.pointerId, local(event));
|
|
2146
|
+
if (fingers.size < 2) return true;
|
|
2147
|
+
const [a, b] = [...fingers.values()];
|
|
2148
|
+
const distance = Math.max(1, Math.hypot(a[0] - b[0], a[1] - b[1]));
|
|
2149
|
+
const middle = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2150
|
+
const p = pinch;
|
|
2151
|
+
onceAFrame(() => {
|
|
2152
|
+
const factor = p.distance / distance;
|
|
2153
|
+
const x = zoomWindow(p.from.x, alongX(p.middle), factor);
|
|
2154
|
+
const y = p.from.y && zoomWindow(p.from.y, alongY(p.middle), factor);
|
|
2155
|
+
const by = [middle[0] - p.middle[0], middle[1] - p.middle[1]];
|
|
2156
|
+
if (zoomAxes.value.x) showX(panWindow(scaleOver(x, p.from.x), -alongX(by)));
|
|
2157
|
+
if (zoomAxes.value.y && y && p.from.y) showY(panWindow(scaleOver(y, p.from.y), -alongY(by)));
|
|
2158
|
+
});
|
|
2159
|
+
return true;
|
|
2160
|
+
}
|
|
2161
|
+
if (!drag || event.pointerId !== drag.id) return false;
|
|
2162
|
+
const at = local(event);
|
|
2163
|
+
const d = drag;
|
|
2164
|
+
const by = [at[0] - d.start[0], at[1] - d.start[1]];
|
|
2165
|
+
if (!d.moved) {
|
|
2166
|
+
if (Math.hypot(...by) < SLOP[d.touch ? "touch" : "mouse"]) return true;
|
|
2167
|
+
const { sideways, upDown } = panDirections();
|
|
2168
|
+
const across = Math.abs(by[0]) >= Math.abs(by[1]);
|
|
2169
|
+
if (!d.mode || d.touch && d.mode === "pan" && !(across ? sideways : upDown)) {
|
|
2170
|
+
endDrag(false);
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
startDrag();
|
|
2174
|
+
}
|
|
2175
|
+
if (d.mode === "pan") onceAFrame(() => panBy(by, d.from));
|
|
2176
|
+
else brushDraft.value = brushRect(d.start, at);
|
|
2177
|
+
return true;
|
|
2178
|
+
}
|
|
2179
|
+
let gestureFrame = 0;
|
|
2180
|
+
let gestureNext = null;
|
|
2181
|
+
/** Runs `step` once a frame, the latest one, however fast the pointer moves. */
|
|
2182
|
+
function onceAFrame(step) {
|
|
2183
|
+
gestureNext = step;
|
|
2184
|
+
if (gestureFrame) return;
|
|
2185
|
+
if (typeof requestAnimationFrame === "undefined") {
|
|
2186
|
+
gestureNext = null;
|
|
2187
|
+
step();
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
gestureFrame = requestAnimationFrame(() => {
|
|
2191
|
+
gestureFrame = 0;
|
|
2192
|
+
const next = gestureNext;
|
|
2193
|
+
gestureNext = null;
|
|
2194
|
+
next?.();
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
/** Ends the drag: `done` when it was let go (not cancelled), which settles a pan and selects. */
|
|
2198
|
+
function endDrag(done, at) {
|
|
2199
|
+
const d = drag;
|
|
2200
|
+
if (!d) return;
|
|
2201
|
+
drag = null;
|
|
2202
|
+
clearTimeout(d.timer);
|
|
2203
|
+
d.hold.release();
|
|
2204
|
+
if (!d.moved) return;
|
|
2205
|
+
if (d.mode === "pan" && rootRef.value) rootRef.value.style.cursor = "";
|
|
2206
|
+
dragged = !d.touch;
|
|
2207
|
+
if (gestureFrame) {
|
|
2208
|
+
cancelAnimationFrame(gestureFrame);
|
|
2209
|
+
gestureFrame = 0;
|
|
2210
|
+
gestureNext?.();
|
|
2211
|
+
gestureNext = null;
|
|
2212
|
+
}
|
|
2213
|
+
if (d.mode === "pan") showX(xWindow.value, true);
|
|
2214
|
+
else if (d.mode === "brush") {
|
|
2215
|
+
const rect = brushDraft.value;
|
|
2216
|
+
brushDraft.value = null;
|
|
2217
|
+
if (done && rect) brushTo(at ? brushRect(d.start, at) : rect);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
function onPointerCancel(event) {
|
|
2221
|
+
tapped = false;
|
|
2222
|
+
fingers.delete(event.pointerId);
|
|
2223
|
+
if (pinch && fingers.size < 2) endPinch();
|
|
2224
|
+
if (drag?.id === event.pointerId) endDrag(false);
|
|
2225
|
+
}
|
|
2226
|
+
function endPinch() {
|
|
2227
|
+
pinch = null;
|
|
2228
|
+
if (gestureFrame) {
|
|
2229
|
+
cancelAnimationFrame(gestureFrame);
|
|
2230
|
+
gestureFrame = 0;
|
|
2231
|
+
gestureNext?.();
|
|
2232
|
+
gestureNext = null;
|
|
2233
|
+
}
|
|
2234
|
+
showX(xWindow.value, true);
|
|
2235
|
+
}
|
|
2236
|
+
function onPointerUp(event) {
|
|
2237
|
+
fingers.delete(event.pointerId);
|
|
2238
|
+
if (pinch) {
|
|
2239
|
+
if (fingers.size < 2) endPinch();
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
const wasDrag = drag?.id === event.pointerId && drag.moved;
|
|
2243
|
+
if (drag?.id === event.pointerId) endDrag(true, local(event));
|
|
2244
|
+
if (!tapped || wasDrag) return;
|
|
2245
|
+
tapped = false;
|
|
2246
|
+
const at = local(event);
|
|
2247
|
+
setActive(hitAt(...at), at, true);
|
|
2248
|
+
}
|
|
2249
|
+
const brushAxes = computed(() => {
|
|
2250
|
+
const axis = brushPart.value?.props.axis ?? "x";
|
|
2251
|
+
return {
|
|
2252
|
+
x: axis !== "y",
|
|
2253
|
+
y: axis !== "x"
|
|
2254
|
+
};
|
|
2255
|
+
});
|
|
2256
|
+
const clampTo = (v, from, length) => Math.min(Math.max(v, from), from + length);
|
|
2257
|
+
/** The selection from `a` to `b` (in px), across the whole plot along an axis it doesn't select. */
|
|
2258
|
+
function brushRect(a, b) {
|
|
2259
|
+
const plot = layout.value?.plot;
|
|
2260
|
+
if (!plot) return null;
|
|
2261
|
+
const x0 = clampTo(Math.min(a[0], b[0]), plot.x, plot.width);
|
|
2262
|
+
const x1 = clampTo(Math.max(a[0], b[0]), plot.x, plot.width);
|
|
2263
|
+
const y0 = clampTo(Math.min(a[1], b[1]), plot.y, plot.height);
|
|
2264
|
+
const y1 = clampTo(Math.max(a[1], b[1]), plot.y, plot.height);
|
|
2265
|
+
const { x: bx, y: by } = brushAxes.value;
|
|
2266
|
+
const horizontal = !!props.horizontal;
|
|
2267
|
+
const across = horizontal ? by : bx;
|
|
2268
|
+
const down = horizontal ? bx : by;
|
|
2269
|
+
return {
|
|
2270
|
+
x: across ? x0 : plot.x,
|
|
2271
|
+
y: down ? y0 : plot.y,
|
|
2272
|
+
width: across ? x1 - x0 : plot.width,
|
|
2273
|
+
height: down ? y1 - y0 : plot.height
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
const inRect = ([px, py], r) => px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height;
|
|
2277
|
+
/** The px a rect spans along x, and along the values. */
|
|
2278
|
+
const spanX = (r) => props.horizontal ? [r.y, r.y + r.height] : [r.x, r.x + r.width];
|
|
2279
|
+
const spanY = (r) => props.horizontal ? [r.x, r.x + r.width] : [r.y, r.y + r.height];
|
|
2280
|
+
/** The values `scale` shows over the px `span`, from the smallest. */
|
|
2281
|
+
function valuesOver(scale, [a, b]) {
|
|
2282
|
+
const [u, v] = [scale.invert(a), scale.invert(b)];
|
|
2283
|
+
return u <= v ? [u, v] : [v, u];
|
|
2284
|
+
}
|
|
2285
|
+
/** Keeps the selection of `rect` (in px) and tells the page; a selection too small to mean one lets it go. */
|
|
2286
|
+
function brushTo(rect) {
|
|
2287
|
+
const l = layout.value;
|
|
2288
|
+
const part = brushPart.value;
|
|
2289
|
+
brushAnchor = null;
|
|
2290
|
+
if (!l || !part || !rect) return;
|
|
2291
|
+
const { x: bx, y: by } = brushAxes.value;
|
|
2292
|
+
const xs = spanX(rect);
|
|
2293
|
+
const ys = spanY(rect);
|
|
2294
|
+
if (bx && xs[1] - xs[0] < 4 || by && ys[1] - ys[0] < 4) {
|
|
2295
|
+
clearBrush();
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
const m = model.value;
|
|
2299
|
+
let x = null;
|
|
2300
|
+
if (bx) {
|
|
2301
|
+
x = valuesOver(l.x.scale, xs);
|
|
2302
|
+
if (m.xKind === "category") {
|
|
2303
|
+
const from = Math.ceil(x[0]);
|
|
2304
|
+
const to = Math.floor(x[1]);
|
|
2305
|
+
if (from > to) {
|
|
2306
|
+
clearBrush();
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
x = [from - .5, to + .5];
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
const primary = primaryY(l);
|
|
2313
|
+
const y = by && primary ? valuesOver(primary.scale, ys) : null;
|
|
2314
|
+
const xInterval = x ? m.xKind === "linear" ? rounded(x) : windowToInterval(x, m.xKind, m.categories) : null;
|
|
2315
|
+
const yInterval = y ? rounded(y) : null;
|
|
2316
|
+
part.x.value = xInterval;
|
|
2317
|
+
part.y.value = yInterval;
|
|
2318
|
+
part.select({
|
|
2319
|
+
...xInterval && { x: xInterval },
|
|
2320
|
+
...yInterval && { y: yInterval },
|
|
2321
|
+
...rowsIn(x, y)
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
/** Where a selection made with Shift and the arrow keys started, along x. */
|
|
2325
|
+
let brushAnchor = null;
|
|
2326
|
+
/** The x of an x or a point picked. */
|
|
2327
|
+
function xOf(target) {
|
|
2328
|
+
if (!target || target.type === "slice") return null;
|
|
2329
|
+
if (target.type === "axis") return target.x;
|
|
2330
|
+
return series.value.find((s) => s.index === target.series)?.x.values[target.row] ?? null;
|
|
2331
|
+
}
|
|
2332
|
+
/** Shift and an arrow: moves on `step` and selects from where it started to there. */
|
|
2333
|
+
function brushWithKeys(step) {
|
|
2334
|
+
const part = brushPart.value;
|
|
2335
|
+
const from = xOf(active.value);
|
|
2336
|
+
const next = moveAlong(step);
|
|
2337
|
+
const to = xOf(next);
|
|
2338
|
+
if (!part || !next || to === null) return;
|
|
2339
|
+
bringIntoView(next);
|
|
2340
|
+
setActive(next, null);
|
|
2341
|
+
brushAnchor ??= from ?? to;
|
|
2342
|
+
const m = model.value;
|
|
2343
|
+
const lo = Math.min(brushAnchor, to);
|
|
2344
|
+
const hi = Math.max(brushAnchor, to);
|
|
2345
|
+
const window = m.xKind === "category" ? [lo - .5, hi + .5] : [lo, hi];
|
|
2346
|
+
const x = windowToInterval(window, m.xKind, m.categories);
|
|
2347
|
+
part.x.value = x;
|
|
2348
|
+
part.y.value = null;
|
|
2349
|
+
part.select({
|
|
2350
|
+
x,
|
|
2351
|
+
...rowsIn(window, null)
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
/** Lets the selection go, and tells the page: nothing is selected. */
|
|
2355
|
+
function clearBrush() {
|
|
2356
|
+
const part = brushPart.value;
|
|
2357
|
+
brushAnchor = null;
|
|
2358
|
+
if (!part || !part.x.value && !part.y.value) return;
|
|
2359
|
+
part.x.value = null;
|
|
2360
|
+
part.y.value = null;
|
|
2361
|
+
part.select({
|
|
2362
|
+
rows: [],
|
|
2363
|
+
series: []
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
/** The rows with a point in the selection: x in `x`, and drawn within `y` of the values. */
|
|
2367
|
+
function rowsIn(x, y) {
|
|
2368
|
+
const l = layout.value;
|
|
2369
|
+
const primary = primaryY(l);
|
|
2370
|
+
const ys = y && primary ? [primary.scale.map(y[0]), primary.scale.map(y[1])] : null;
|
|
2371
|
+
const lowY = ys ? Math.min(...ys) - .5 : 0;
|
|
2372
|
+
const highY = ys ? Math.max(...ys) + .5 : 0;
|
|
2373
|
+
const all = /* @__PURE__ */ new Set();
|
|
2374
|
+
const bySeries = [];
|
|
2375
|
+
for (const s of series.value) {
|
|
2376
|
+
const view = viewOf(s);
|
|
2377
|
+
if (!view) continue;
|
|
2378
|
+
const rows = [];
|
|
2379
|
+
for (const i of sortedOrder(s.x.values)) {
|
|
2380
|
+
const value = s.x.values[i];
|
|
2381
|
+
const top = s.top[i];
|
|
2382
|
+
if (top !== top || x && !within(value, x)) continue;
|
|
2383
|
+
if (ys) {
|
|
2384
|
+
const at = alongY(view.point(value, top));
|
|
2385
|
+
if (at < lowY || at > highY) continue;
|
|
2386
|
+
}
|
|
2387
|
+
rows.push(rowOf(s.rows, i));
|
|
2388
|
+
all.add(rowOf(s.rows, i));
|
|
2389
|
+
}
|
|
2390
|
+
bySeries.push({
|
|
2391
|
+
name: s.name,
|
|
2392
|
+
rows
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
rows: [...all],
|
|
2397
|
+
series: bySeries
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
/** The selection to keep the rest faded around, while it is dragged or kept. */
|
|
2401
|
+
const dimmed = computed(() => brushPart.value && brushPart.value.props.dim !== false ? brushDraft.value ?? selection.value : null);
|
|
2402
|
+
/** The selection kept (`v-model:x` / `v-model:y` of the brush), in px over the plot. */
|
|
2403
|
+
const selection = computed(() => {
|
|
2404
|
+
const part = brushPart.value;
|
|
2405
|
+
const l = layout.value;
|
|
2406
|
+
if (!part || !l || !part.x.value && !part.y.value) return null;
|
|
2407
|
+
const m = model.value;
|
|
2408
|
+
const { plot } = l;
|
|
2409
|
+
const x = intervalToWindow(part.x.value, m.xKind, m.categories);
|
|
2410
|
+
const y = part.y.value ? intervalToWindow(part.y.value, "linear", []) : null;
|
|
2411
|
+
const primary = primaryY(l);
|
|
2412
|
+
const xs = x ? [l.x.scale.map(x[0]), l.x.scale.map(x[1])] : props.horizontal ? [plot.y, plot.y + plot.height] : [plot.x, plot.x + plot.width];
|
|
2413
|
+
const ys = y && primary ? [primary.scale.map(y[0]), primary.scale.map(y[1])] : props.horizontal ? [plot.x, plot.x + plot.width] : [plot.y + plot.height, plot.y];
|
|
2414
|
+
const [a, b] = props.horizontal ? [ys, xs] : [xs, ys];
|
|
2415
|
+
const left = Math.max(plot.x, Math.min(a[0], a[1]));
|
|
2416
|
+
const right = Math.min(plot.x + plot.width, Math.max(a[0], a[1]));
|
|
2417
|
+
const top = Math.max(plot.y, Math.min(b[0], b[1]));
|
|
2418
|
+
const bottom = Math.min(plot.y + plot.height, Math.max(b[0], b[1]));
|
|
2419
|
+
if (right <= left || bottom <= top) return null;
|
|
2420
|
+
return {
|
|
2421
|
+
x: left,
|
|
2422
|
+
y: top,
|
|
2423
|
+
width: right - left,
|
|
2424
|
+
height: bottom - top
|
|
2425
|
+
};
|
|
2426
|
+
});
|
|
2427
|
+
/** How much a wheel turn zooms, per px of its delta (pinches send small deltas, wheels large ones). */
|
|
2428
|
+
function onWheel(event) {
|
|
2429
|
+
if (!zoomOn.value) return;
|
|
2430
|
+
const at = local(event);
|
|
2431
|
+
if (!inPlot(at)) return;
|
|
2432
|
+
const lines = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1;
|
|
2433
|
+
let dx = event.deltaX * lines;
|
|
2434
|
+
let dy = event.deltaY * lines;
|
|
2435
|
+
const pinching = event.ctrlKey || event.metaKey;
|
|
2436
|
+
if (pinching || zoomPart.value?.props.wheel) {
|
|
2437
|
+
if (!pinching && !zoomed.value && dy > 0) return;
|
|
2438
|
+
event.preventDefault();
|
|
2439
|
+
const d = Math.max(-40, Math.min(WHEEL_MOST, dy));
|
|
2440
|
+
setActive(null, null);
|
|
2441
|
+
onceAFrame(() => zoomBy(Math.exp(d * WHEEL_ZOOM), at));
|
|
2442
|
+
settleSoon();
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (event.shiftKey && !dx) [dx, dy] = [dy, 0];
|
|
2446
|
+
if (!zoomed.value || !(Math.abs(dx) > Math.abs(dy))) return;
|
|
2447
|
+
event.preventDefault();
|
|
2448
|
+
setActive(null, null);
|
|
2449
|
+
const by = props.horizontal ? [0, -dx] : [-dx, 0];
|
|
2450
|
+
onceAFrame(() => panBy(by));
|
|
2451
|
+
settleSoon();
|
|
2452
|
+
}
|
|
2453
|
+
let settleTimer;
|
|
2454
|
+
/** Once the wheel stops, categories cut part way are shown whole. */
|
|
2455
|
+
function settleSoon() {
|
|
2456
|
+
clearTimeout(settleTimer);
|
|
2457
|
+
settleTimer = setTimeout(() => showX(xWindow.value, true), 160);
|
|
2458
|
+
}
|
|
2459
|
+
onBeforeUnmount(() => clearTimeout(settleTimer));
|
|
2460
|
+
function onDblclick(event) {
|
|
2461
|
+
if (!zoomOn.value || !inPlot(local(event))) return;
|
|
2462
|
+
event.preventDefault();
|
|
2463
|
+
resetZoom();
|
|
2464
|
+
}
|
|
2465
|
+
function onOutsidePress(event) {
|
|
2466
|
+
if (!active.value) return;
|
|
2467
|
+
const root = rootRef.value;
|
|
2468
|
+
if (root && !root.contains(event.target)) setActive(null, null);
|
|
2469
|
+
}
|
|
2470
|
+
onMounted(() => document.addEventListener("pointerdown", onOutsidePress, true));
|
|
2471
|
+
onBeforeUnmount(() => {
|
|
2472
|
+
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
2473
|
+
if (pointerFrame) cancelAnimationFrame(pointerFrame);
|
|
2474
|
+
});
|
|
2475
|
+
function onKeydown(event) {
|
|
2476
|
+
if (!interactive.value || event.target !== rootRef.value) return;
|
|
2477
|
+
if (event.key === "Enter") {
|
|
2478
|
+
if (tip.value) select(tip.value);
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
if (event.key === "Escape") {
|
|
2482
|
+
if (active.value) setActive(null, null);
|
|
2483
|
+
else if (selection.value) clearBrush();
|
|
2484
|
+
else return;
|
|
2485
|
+
event.stopPropagation();
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (zoomOn.value && zoomKey(event)) {
|
|
2489
|
+
event.preventDefault();
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const horizontal = !!props.horizontal;
|
|
2493
|
+
const switches = !pie.value && !tree.value && !funnel.value && !radar.value && trigger.value !== "axis" && (series.value.length > 1 || series.value.some((s) => s.kind.categoryY));
|
|
2494
|
+
const along = !switches ? {
|
|
2495
|
+
ArrowRight: 1,
|
|
2496
|
+
ArrowDown: 1,
|
|
2497
|
+
ArrowLeft: -1,
|
|
2498
|
+
ArrowUp: -1
|
|
2499
|
+
} : horizontal ? {
|
|
2500
|
+
ArrowDown: 1,
|
|
2501
|
+
ArrowUp: -1
|
|
2502
|
+
} : {
|
|
2503
|
+
ArrowRight: 1,
|
|
2504
|
+
ArrowLeft: -1
|
|
2505
|
+
};
|
|
2506
|
+
const between = !switches ? {} : horizontal ? {
|
|
2507
|
+
ArrowRight: 1,
|
|
2508
|
+
ArrowLeft: -1
|
|
2509
|
+
} : {
|
|
2510
|
+
ArrowUp: 1,
|
|
2511
|
+
ArrowDown: -1
|
|
2512
|
+
};
|
|
2513
|
+
const step = along[event.key] ?? (event.key === "Home" ? -Infinity : event.key === "End" ? Infinity : 0);
|
|
2514
|
+
const turn = between[event.key] ?? 0;
|
|
2515
|
+
if (event.shiftKey && brushPart.value && brushAxes.value.x && !pie.value && (step || turn)) {
|
|
2516
|
+
event.preventDefault();
|
|
2517
|
+
if (step) brushWithKeys(step);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
if (step || turn) brushAnchor = null;
|
|
2521
|
+
const next = step ? moveAlong(step) : turn ? moveBetween(turn) : void 0;
|
|
2522
|
+
if (next === void 0) return;
|
|
2523
|
+
event.preventDefault();
|
|
2524
|
+
if (next) bringIntoView(next);
|
|
2525
|
+
if (next) setActive(next, null);
|
|
2526
|
+
}
|
|
2527
|
+
/** `+` and `-` zoom about what is picked (or the middle), `0` shows all; `true` when it was one. */
|
|
2528
|
+
function zoomKey(event) {
|
|
2529
|
+
const l = layout.value;
|
|
2530
|
+
if (!l || event.ctrlKey || event.metaKey || event.altKey) return false;
|
|
2531
|
+
const factor = event.key === "+" || event.key === "=" ? .5 : event.key === "-" ? 2 : 0;
|
|
2532
|
+
if (event.key === "0") {
|
|
2533
|
+
resetZoom(true);
|
|
2534
|
+
return true;
|
|
2535
|
+
}
|
|
2536
|
+
if (!factor) return false;
|
|
2537
|
+
const { plot } = l;
|
|
2538
|
+
const t = tip.value;
|
|
2539
|
+
const at = t ? [t.anchor[0], t.anchor[1]] : [plot.x + plot.width / 2, plot.y + plot.height / 2];
|
|
2540
|
+
if (t?.x !== void 0 && active.value?.type === "axis") {
|
|
2541
|
+
const x = l.x.scale.map(active.value.x);
|
|
2542
|
+
if (props.horizontal) at[1] = x;
|
|
2543
|
+
else at[0] = x;
|
|
2544
|
+
}
|
|
2545
|
+
zoomBy(factor, at);
|
|
2546
|
+
showX(xWindow.value, true);
|
|
2547
|
+
const now = layout.value;
|
|
2548
|
+
if (!now) return true;
|
|
2549
|
+
const [from, to] = now.x.scale.domain;
|
|
2550
|
+
const m = model.value;
|
|
2551
|
+
const ends = m.xKind === "category" ? windowToInterval([from, to], m.xKind, m.categories).map(String) : [xText(from), xText(to)];
|
|
2552
|
+
zoomNote.value = zoomed.value ? format(messages.value.zoomed, {
|
|
2553
|
+
from: ends[0],
|
|
2554
|
+
to: ends[1]
|
|
2555
|
+
}) : messages.value.zoomReset;
|
|
2556
|
+
return true;
|
|
2557
|
+
}
|
|
2558
|
+
/** Zoomed in: moves what is shown so the x the keys moved to is in sight. */
|
|
2559
|
+
function bringIntoView(target) {
|
|
2560
|
+
if (!layout.value || !xWindow.value || target.type === "slice") return;
|
|
2561
|
+
const x = target.type === "axis" ? target.x : series.value.find((s) => s.index === target.series)?.x.values[target.row];
|
|
2562
|
+
if (x === void 0) return;
|
|
2563
|
+
const [a, b] = xWindow.value;
|
|
2564
|
+
const span = b - a;
|
|
2565
|
+
const room = model.value.xKind === "category" ? .5 : span * .05;
|
|
2566
|
+
if (x - room < a) showX([x - room, x - room + span], true);
|
|
2567
|
+
else if (x + room > b) showX([x + room - span, x + room], true);
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* The place `step` on from `at` among `n`, kept to the ends; from nothing
|
|
2571
|
+
* picked, the first (or the last, going back).
|
|
2572
|
+
*/
|
|
2573
|
+
function stepTo(at, step, n) {
|
|
2574
|
+
if (at < 0 && Number.isFinite(step)) return step > 0 ? 0 : n - 1;
|
|
2575
|
+
return Math.max(0, Math.min(n - 1, at + step));
|
|
2576
|
+
}
|
|
2577
|
+
/** The next x, point or slice `step` along (±Infinity for the ends). */
|
|
2578
|
+
function moveAlong(step) {
|
|
2579
|
+
const a = active.value;
|
|
2580
|
+
if (tree.value) {
|
|
2581
|
+
const count = (treeData.value?.nodes.length ?? 1) - 1;
|
|
2582
|
+
if (count < 1) return null;
|
|
2583
|
+
return {
|
|
2584
|
+
type: "slice",
|
|
2585
|
+
slice: stepTo(a?.type === "slice" ? a.slice - 1 : -1, step, count) + 1
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
if (pie.value) {
|
|
2589
|
+
const shown = slices.value.flatMap((slice, k) => slice.hidden ? [] : [k]);
|
|
2590
|
+
if (!shown.length) return null;
|
|
2591
|
+
return {
|
|
2592
|
+
type: "slice",
|
|
2593
|
+
slice: shown[stepTo(a?.type === "slice" ? shown.indexOf(a.slice) : -1, step, shown.length)]
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
if (trigger.value === "axis") {
|
|
2597
|
+
const xs = xValues.value;
|
|
2598
|
+
if (!xs.length) return null;
|
|
2599
|
+
const at = a?.type === "axis" ? xs.indexOf(a.x) : -1;
|
|
2600
|
+
return {
|
|
2601
|
+
type: "axis",
|
|
2602
|
+
x: xs[radar.value && at >= 0 && Number.isFinite(step) ? (at + step + xs.length) % xs.length : stepTo(at, step, xs.length)]
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
const s = a?.type === "item" && series.value.find((x) => x.index === a.series) || series.value[0];
|
|
2606
|
+
if (!s) return null;
|
|
2607
|
+
const mine = a?.type === "item" && a.series === s.index;
|
|
2608
|
+
const order = s.kind.categoryY && mine ? pointsOf(s, s.top[a.row]) : pointsOf(s);
|
|
2609
|
+
if (!order.length) return null;
|
|
2610
|
+
return itemOf(s, order[stepTo(mine ? order.indexOf(a.row) : -1, step, order.length)]);
|
|
2611
|
+
}
|
|
2612
|
+
/** The point of the next series `turn` away, nearest to the current x. */
|
|
2613
|
+
function moveBetween(turn) {
|
|
2614
|
+
const a = active.value;
|
|
2615
|
+
if (pie.value || trigger.value === "axis") return void 0;
|
|
2616
|
+
const from = a?.type === "item" ? series.value.find((x) => x.index === a.series) : void 0;
|
|
2617
|
+
if (from?.kind.categoryY && a?.type === "item") return heatRow(from, a.row, turn);
|
|
2618
|
+
const list = series.value.filter((s) => pointsOf(s).length);
|
|
2619
|
+
if (!list.length) return null;
|
|
2620
|
+
const at = a?.type === "item" ? list.findIndex((s) => s.index === a.series) : -1;
|
|
2621
|
+
const s = list[at < 0 ? 0 : (at + turn + list.length) % list.length];
|
|
2622
|
+
const order = pointsOf(s);
|
|
2623
|
+
if (a?.type !== "item") return itemOf(s, order[0]);
|
|
2624
|
+
const x = from ? from.x.values[a.row] : 0;
|
|
2625
|
+
let best = order[0];
|
|
2626
|
+
for (const row of order) if (Math.abs(s.x.values[row] - x) < Math.abs(s.x.values[best] - x)) best = row;
|
|
2627
|
+
return itemOf(s, best);
|
|
2628
|
+
}
|
|
2629
|
+
/** The rows of `s` with a value, from the smallest x; for a heatmap, only those in its row `place`. */
|
|
2630
|
+
function pointsOf(s, place) {
|
|
2631
|
+
const order = sortedOrder(s.x.values);
|
|
2632
|
+
const out = [];
|
|
2633
|
+
for (const row of order) if (s.top[row] === s.top[row] && (place === void 0 || s.top[row] === place)) out.push(row);
|
|
2634
|
+
return out;
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* The heatmap cell a row up or down from `row` (across, when horizontal),
|
|
2638
|
+
* at the same x: `turn` 1 is up (right), as the keys go. Rows without a
|
|
2639
|
+
* cell there are passed over; at the edge it stays.
|
|
2640
|
+
*/
|
|
2641
|
+
function heatRow(s, row, turn) {
|
|
2642
|
+
const view = viewOf(s);
|
|
2643
|
+
const x = s.x.values[row];
|
|
2644
|
+
let step = turn;
|
|
2645
|
+
if (view) {
|
|
2646
|
+
const [l0, t0] = view.point(x, 0);
|
|
2647
|
+
const [l1, t1] = view.point(x, 1);
|
|
2648
|
+
if ((props.horizontal ? l1 - l0 : t0 - t1) < 0) step = -turn;
|
|
2649
|
+
}
|
|
2650
|
+
const n = model.value.yCategories[s.axis]?.length ?? 0;
|
|
2651
|
+
for (let place = s.top[row] + step; place >= 0 && place < n; place += step) for (let i = 0; i < s.y.length; i++) if (s.top[i] === place && s.x.values[i] === x && s.y[i] === s.y[i]) return itemOf(s, i);
|
|
2652
|
+
return itemOf(s, row);
|
|
2653
|
+
}
|
|
2654
|
+
function itemOf(s, row) {
|
|
2655
|
+
const view = viewOf(s);
|
|
2656
|
+
const x = s.x.values[row];
|
|
2657
|
+
const at = view?.point(x, s.top[row]);
|
|
2658
|
+
const hit = view && at && s.kind.hit ? s.kind.hit(s, view, at[0], at[1]) : null;
|
|
2659
|
+
return {
|
|
2660
|
+
type: "item",
|
|
2661
|
+
series: s.index,
|
|
2662
|
+
row,
|
|
2663
|
+
radius: hit?.index === row ? hit.radius : 4
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
/** Every x with a value, from the smallest: where the keys stop. */
|
|
2667
|
+
const xValues = computed(() => allX(series.value.map((s) => s.x.values)));
|
|
2668
|
+
watch([series, slices], () => {
|
|
2669
|
+
const a = active.value;
|
|
2670
|
+
if (!a) return;
|
|
2671
|
+
if (a.pointer) setActive(hitAt(...a.pointer), a.pointer, a.touch);
|
|
2672
|
+
else if (!tip.value) setActive(null, null);
|
|
2673
|
+
});
|
|
2674
|
+
/** Read out as the keys move: the x (or series) and its values, or what zooming shows. */
|
|
2675
|
+
const live = computed(() => {
|
|
2676
|
+
if (zoomNote.value) return zoomNote.value;
|
|
2677
|
+
const t = tip.value;
|
|
2678
|
+
if (!t || active.value?.pointer || active.value?.synced) return "";
|
|
2679
|
+
const items = t.items.map((item) => `${item.name} ${item.text}`).join(messages.value.itemSeparator);
|
|
2680
|
+
const text = t.title ? `${t.title}${messages.value.itemSeparator}${items}` : items;
|
|
2681
|
+
return t.note ? `${text}${messages.value.itemSeparator}${t.note}` : text;
|
|
2682
|
+
});
|
|
2683
|
+
const pointer = {
|
|
2684
|
+
tip,
|
|
2685
|
+
size: computed(() => {
|
|
2686
|
+
const l = layout.value ?? pieLayout.value ?? treeLayout.value ?? radarLayout.value ?? funnelLayout.value;
|
|
2687
|
+
return l && {
|
|
2688
|
+
width: l.width,
|
|
2689
|
+
height: l.height
|
|
2690
|
+
};
|
|
2691
|
+
}),
|
|
2692
|
+
content: computed(() => tooltipPart.value?.content()),
|
|
2693
|
+
live
|
|
2694
|
+
};
|
|
2695
|
+
let pending = 0;
|
|
2696
|
+
function request() {
|
|
2697
|
+
if (pending || typeof requestAnimationFrame === "undefined") return;
|
|
2698
|
+
pending = requestAnimationFrame(() => {
|
|
2699
|
+
pending = 0;
|
|
2700
|
+
draw();
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
onBeforeUnmount(() => pending && cancelAnimationFrame(pending));
|
|
2704
|
+
watch(() => [
|
|
2705
|
+
layout.value,
|
|
2706
|
+
pieLayout.value,
|
|
2707
|
+
series.value,
|
|
2708
|
+
sliceColors.value,
|
|
2709
|
+
colors.value,
|
|
2710
|
+
seriesParts.value.map((part) => ({ ...part.props })),
|
|
2711
|
+
pie.value && { ...pie.value.props },
|
|
2712
|
+
focused.value,
|
|
2713
|
+
pointed.value,
|
|
2714
|
+
pieLabels.value,
|
|
2715
|
+
labelsShown.value,
|
|
2716
|
+
pie.value && active.value,
|
|
2717
|
+
radarLayout.value,
|
|
2718
|
+
funnelLayout.value,
|
|
2719
|
+
treeLayout.value,
|
|
2720
|
+
treeColors.value,
|
|
2721
|
+
tree.value && { ...tree.value.props },
|
|
2722
|
+
tree.value && active.value,
|
|
2723
|
+
radar.value && active.value,
|
|
2724
|
+
dimmed.value
|
|
2725
|
+
], request, { flush: "post" });
|
|
2726
|
+
let steady = 0;
|
|
2727
|
+
let idle = 0;
|
|
2728
|
+
function cancelPrepare() {
|
|
2729
|
+
if (steady) clearTimeout(steady);
|
|
2730
|
+
if (idle) (typeof cancelIdleCallback === "function" ? cancelIdleCallback : clearTimeout)(idle);
|
|
2731
|
+
steady = idle = 0;
|
|
2732
|
+
}
|
|
2733
|
+
watch(series, (list) => {
|
|
2734
|
+
cancelPrepare();
|
|
2735
|
+
if (!list.some((s) => s.kind.prepare)) return;
|
|
2736
|
+
const work = () => {
|
|
2737
|
+
idle = 0;
|
|
2738
|
+
for (const s of list) if (s.kind.prepare) s.prepared = s.kind.prepare(s);
|
|
2739
|
+
};
|
|
2740
|
+
steady = setTimeout(() => {
|
|
2741
|
+
steady = 0;
|
|
2742
|
+
idle = typeof requestIdleCallback === "function" ? requestIdleCallback(work, { timeout: 1e3 }) : setTimeout(work);
|
|
2743
|
+
}, STEADY);
|
|
2744
|
+
}, { flush: "post" });
|
|
2745
|
+
onBeforeUnmount(cancelPrepare);
|
|
2746
|
+
const reduced = useReducedMotion();
|
|
2747
|
+
/** Above this many points, changes show at once. */
|
|
2748
|
+
const easeOut = (t) => 1 - (1 - t) ** 3;
|
|
2749
|
+
let motion = null;
|
|
2750
|
+
/** The model and slices last drawn, and how they were drawn, to move from. */
|
|
2751
|
+
let drawnModel = null;
|
|
2752
|
+
let drawnSlices = null;
|
|
2753
|
+
let lastSeries = [];
|
|
2754
|
+
let lastSlices = [];
|
|
2755
|
+
function pointCount() {
|
|
2756
|
+
if (pie.value) return slices.value.length;
|
|
2757
|
+
let n = 0;
|
|
2758
|
+
for (const s of model.value.series) n += s.y.length;
|
|
2759
|
+
return n;
|
|
2760
|
+
}
|
|
2761
|
+
/** Whether the chart has been on screen: it comes in when first seen, not while scrolled away. */
|
|
2762
|
+
let seen = typeof IntersectionObserver === "undefined";
|
|
2763
|
+
/**
|
|
2764
|
+
* Whether the chart is on screen, or near it. Off screen nothing is drawn
|
|
2765
|
+
* (live data keeps coming), and it is drawn as it is when it comes back.
|
|
2766
|
+
*/
|
|
2767
|
+
let onScreen = true;
|
|
2768
|
+
let missed = false;
|
|
2769
|
+
let watcher;
|
|
2770
|
+
onMounted(() => {
|
|
2771
|
+
if (typeof IntersectionObserver === "undefined" || !rootRef.value) return;
|
|
2772
|
+
watcher = new IntersectionObserver((entries) => {
|
|
2773
|
+
const entry = entries[entries.length - 1];
|
|
2774
|
+
if (!entry) return;
|
|
2775
|
+
onScreen = entry.isIntersecting;
|
|
2776
|
+
if (!onScreen) return;
|
|
2777
|
+
if (!seen || missed) request();
|
|
2778
|
+
seen = true;
|
|
2779
|
+
missed = false;
|
|
2780
|
+
}, { rootMargin: "200px 0px" });
|
|
2781
|
+
watcher.observe(rootRef.value);
|
|
2782
|
+
});
|
|
2783
|
+
onBeforeUnmount(() => watcher?.disconnect());
|
|
2784
|
+
/** Starts a move when the data changed since the last frame. */
|
|
2785
|
+
function startMotion(now) {
|
|
2786
|
+
if (!seen) return;
|
|
2787
|
+
const m = model.value;
|
|
2788
|
+
const changed = pie.value ? drawnSlices !== slices.value : drawnModel !== m;
|
|
2789
|
+
const first = pie.value ? !drawnSlices : !drawnModel;
|
|
2790
|
+
if (!changed || empty.value) return;
|
|
2791
|
+
drawnModel = m;
|
|
2792
|
+
drawnSlices = slices.value;
|
|
2793
|
+
if (reduced.value || pointCount() > MOTION_LIMIT) {
|
|
2794
|
+
motion = null;
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
const from = /* @__PURE__ */ new Map();
|
|
2798
|
+
const angles = /* @__PURE__ */ new Map();
|
|
2799
|
+
if (!first) {
|
|
2800
|
+
for (const s of lastSeries) from.set(s.index, {
|
|
2801
|
+
top: s.top,
|
|
2802
|
+
base: s.base,
|
|
2803
|
+
ends: s.stackEnds
|
|
2804
|
+
});
|
|
2805
|
+
for (const slice of lastSlices) angles.set(slice.index, [slice.start, slice.end]);
|
|
2806
|
+
}
|
|
2807
|
+
if (first) labelsShown.value = false;
|
|
2808
|
+
motion = {
|
|
2809
|
+
start: now,
|
|
2810
|
+
duration: first ? ENTER : UPDATE,
|
|
2811
|
+
enter: first,
|
|
2812
|
+
series: from,
|
|
2813
|
+
slices: angles
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
function lerp(from, to, t) {
|
|
2817
|
+
if (from.length !== to.length) return to;
|
|
2818
|
+
const out = new Float64Array(to.length);
|
|
2819
|
+
for (let i = 0; i < to.length; i++) {
|
|
2820
|
+
const a = from[i];
|
|
2821
|
+
const b = to[i];
|
|
2822
|
+
out[i] = a === a ? a + (b - a) * t : b;
|
|
2823
|
+
}
|
|
2824
|
+
return out;
|
|
2825
|
+
}
|
|
2826
|
+
/** The series `t` of the way (0 to 1) from what was shown. */
|
|
2827
|
+
function shownSeries(t) {
|
|
2828
|
+
const list = series.value;
|
|
2829
|
+
if (!motion || t >= 1) return list;
|
|
2830
|
+
return list.map((s) => {
|
|
2831
|
+
let from = motion.series.get(s.index);
|
|
2832
|
+
if (!from && s.kind.zero) {
|
|
2833
|
+
const zero = new Float64Array(s.top.length).fill(baselineValue(s));
|
|
2834
|
+
const base = motion.enter ? s.base && zero : s.base;
|
|
2835
|
+
const start = base ?? zero;
|
|
2836
|
+
from = {
|
|
2837
|
+
top: start,
|
|
2838
|
+
base,
|
|
2839
|
+
ends: s.stackEnds && {
|
|
2840
|
+
up: start,
|
|
2841
|
+
down: start
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
if (!from) return s;
|
|
2846
|
+
return {
|
|
2847
|
+
...s,
|
|
2848
|
+
top: lerp(from.top, s.top, t),
|
|
2849
|
+
base: s.base && from.base ? lerp(from.base, s.base, t) : s.base,
|
|
2850
|
+
stackEnds: s.stackEnds && from.ends ? {
|
|
2851
|
+
up: lerp(from.ends.up, s.stackEnds.up, t),
|
|
2852
|
+
down: lerp(from.ends.down, s.stackEnds.down, t)
|
|
2853
|
+
} : s.stackEnds
|
|
2854
|
+
};
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
/** Where a series' bars grow from: 0, or the end of the axis nearest to it. */
|
|
2858
|
+
function baselineValue(s) {
|
|
2859
|
+
const axis = layout.value?.y[s.axis];
|
|
2860
|
+
if (!axis) return 0;
|
|
2861
|
+
const [lo, hi] = axis.scale.domain;
|
|
2862
|
+
return Math.min(Math.max(0, lo), hi);
|
|
2863
|
+
}
|
|
2864
|
+
function shownSlicesAt(t) {
|
|
2865
|
+
const list = slices.value;
|
|
2866
|
+
if (!motion || motion.enter || t >= 1) return list;
|
|
2867
|
+
return list.map((slice) => {
|
|
2868
|
+
const from = motion.slices.get(slice.index);
|
|
2869
|
+
if (!from) return slice;
|
|
2870
|
+
return {
|
|
2871
|
+
...slice,
|
|
2872
|
+
start: from[0] + (slice.start - from[0]) * t,
|
|
2873
|
+
end: from[1] + (slice.end - from[1]) * t
|
|
2874
|
+
};
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
function draw() {
|
|
2878
|
+
const canvas = canvasRef.value;
|
|
2879
|
+
const l = layout.value ?? pieLayout.value ?? treeLayout.value ?? radarLayout.value ?? funnelLayout.value;
|
|
2880
|
+
if (!canvas || !l) return;
|
|
2881
|
+
if (!onScreen) {
|
|
2882
|
+
missed = true;
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const now = typeof performance === "undefined" ? Date.now() : performance.now();
|
|
2886
|
+
if (fromServer.value) {
|
|
2887
|
+
drawnModel = model.value;
|
|
2888
|
+
drawnSlices = slices.value;
|
|
2889
|
+
}
|
|
2890
|
+
startMotion(now);
|
|
2891
|
+
const dpr = window.devicePixelRatio || 1;
|
|
2892
|
+
const width = Math.round(l.width * dpr);
|
|
2893
|
+
const height = Math.round(l.height * dpr);
|
|
2894
|
+
if (canvas.width !== width) canvas.width = width;
|
|
2895
|
+
if (canvas.height !== height) canvas.height = height;
|
|
2896
|
+
canvas.style.width = `${l.width}px`;
|
|
2897
|
+
canvas.style.height = `${l.height}px`;
|
|
2898
|
+
const ctx = canvas.getContext("2d");
|
|
2899
|
+
if (!ctx) return;
|
|
2900
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2901
|
+
ctx.clearRect(0, 0, l.width, l.height);
|
|
2902
|
+
if (empty.value) return;
|
|
2903
|
+
const done = motion ? Math.min(1, (now - motion.start) / motion.duration) : 1;
|
|
2904
|
+
const t = easeOut(done);
|
|
2905
|
+
const entering = motion?.enter ? t : 1;
|
|
2906
|
+
if (pieLayout.value) paintPie(ctx, t, entering);
|
|
2907
|
+
else if (treeLayout.value) paintTree(ctx, entering, false, dpr);
|
|
2908
|
+
else if (radarLayout.value) paintRadar(ctx, dpr, t);
|
|
2909
|
+
else if (funnelLayout.value) paintFunnel(ctx, dpr, t);
|
|
2910
|
+
else paint(ctx, dpr, t, entering);
|
|
2911
|
+
fromServer.value = false;
|
|
2912
|
+
if (done >= 1) motion = null;
|
|
2913
|
+
else request();
|
|
2914
|
+
if (!motion?.enter) labelsShown.value = true;
|
|
2915
|
+
}
|
|
2916
|
+
/** Draws the pie; `still` leaves out what follows the pointer and the legend (an export). */
|
|
2917
|
+
function paintPie(painter, t, sweep, still = false) {
|
|
2918
|
+
const l = pieLayout.value;
|
|
2919
|
+
const a = still ? null : active.value;
|
|
2920
|
+
const legendFocus = still || focused.value === null ? void 0 : slices.value[focused.value];
|
|
2921
|
+
const hovered = a?.type === "slice" ? slices.value[a.slice] : void 0;
|
|
2922
|
+
const focus = legendFocus && !legendFocus.hidden ? legendFocus : hovered;
|
|
2923
|
+
const shown = shownSlicesAt(t);
|
|
2924
|
+
if (!still) lastSlices = shown;
|
|
2925
|
+
drawPie(painter, shown, sliceColors.value, {
|
|
2926
|
+
cx: l.cx,
|
|
2927
|
+
cy: l.cy,
|
|
2928
|
+
radius: l.radius,
|
|
2929
|
+
inner: l.radius * holeRatio.value,
|
|
2930
|
+
surface: colors.value.surface,
|
|
2931
|
+
focus: focus?.index,
|
|
2932
|
+
fade: legendFocus ? FADED : .56,
|
|
2933
|
+
sweep,
|
|
2934
|
+
from: pieArc.value[0],
|
|
2935
|
+
patterns: slicePatterns.value
|
|
2936
|
+
});
|
|
2937
|
+
const leaders = pieLabels.value.leaders;
|
|
2938
|
+
if (!leaders.length || !(still || labelsShown.value)) return;
|
|
2939
|
+
painter.save();
|
|
2940
|
+
painter.strokeStyle = colors.value.baseline;
|
|
2941
|
+
painter.lineWidth = 1;
|
|
2942
|
+
painter.lineJoin = "round";
|
|
2943
|
+
painter.beginPath();
|
|
2944
|
+
for (const line of leaders) line.forEach(([x, y], k) => k ? painter.lineTo(x, y) : painter.moveTo(x, y));
|
|
2945
|
+
painter.stroke();
|
|
2946
|
+
painter.restore();
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Draws the radar: its rings and spokes, the spoke picked, then each
|
|
2950
|
+
* series, those not brought forward faded. `still` leaves out what follows
|
|
2951
|
+
* the pointer and the legend (an export).
|
|
2952
|
+
*/
|
|
2953
|
+
function paintRadar(painter, dpr, t, still = false) {
|
|
2954
|
+
const r = radarLayout.value;
|
|
2955
|
+
const { cx, cy, count } = r;
|
|
2956
|
+
const corner = (k, out) => {
|
|
2957
|
+
const angle = spokeAngle(k, count);
|
|
2958
|
+
return [cx + Math.sin(angle) * out, cy - Math.cos(angle) * out];
|
|
2959
|
+
};
|
|
2960
|
+
painter.save();
|
|
2961
|
+
painter.lineWidth = 1;
|
|
2962
|
+
painter.strokeStyle = colors.value.grid;
|
|
2963
|
+
painter.beginPath();
|
|
2964
|
+
if (axes.value.y?.grid !== false) for (const tick of r.ticks) {
|
|
2965
|
+
const out = r.scale.map(tick);
|
|
2966
|
+
if (out < 1 || count < 3) continue;
|
|
2967
|
+
for (let k = 0; k <= count; k++) {
|
|
2968
|
+
const [x, y] = corner(k % count, out);
|
|
2969
|
+
if (k) painter.lineTo(x, y);
|
|
2970
|
+
else painter.moveTo(x, y);
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
for (let k = 0; k < count; k++) {
|
|
2974
|
+
const [x, y] = corner(k, r.radius);
|
|
2975
|
+
painter.moveTo(cx, cy);
|
|
2976
|
+
painter.lineTo(x, y);
|
|
2977
|
+
}
|
|
2978
|
+
painter.stroke();
|
|
2979
|
+
const a = still ? null : active.value;
|
|
2980
|
+
if (a?.type === "axis" && count) {
|
|
2981
|
+
const [x, y] = corner(a.x, r.radius);
|
|
2982
|
+
painter.strokeStyle = colors.value.baseline;
|
|
2983
|
+
painter.beginPath();
|
|
2984
|
+
painter.moveTo(cx, cy);
|
|
2985
|
+
painter.lineTo(x, y);
|
|
2986
|
+
painter.stroke();
|
|
2987
|
+
}
|
|
2988
|
+
painter.restore();
|
|
2989
|
+
ringValues(painter, r);
|
|
2990
|
+
const shown = shownSeries(t);
|
|
2991
|
+
if (!still) lastSeries = shown;
|
|
2992
|
+
const focus = still ? -1 : focused.value ?? -1;
|
|
2993
|
+
const fading = focus >= 0 && shown.some((s) => s.index === focus);
|
|
2994
|
+
const vector = isSvg(painter);
|
|
2995
|
+
const order = fading ? [...shown.filter((s) => s.index !== focus), ...shown.filter((s) => s.index === focus)] : shown;
|
|
2996
|
+
for (const s of order) {
|
|
2997
|
+
const view = radarView(r, dpr, vector);
|
|
2998
|
+
painter.save();
|
|
2999
|
+
if (fading && s.index !== focus) painter.globalAlpha = FADED;
|
|
3000
|
+
s.kind.draw(painter, s, view);
|
|
3001
|
+
painter.restore();
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
/** Draws the funnel, as it comes in: its bars grow from the middle. */
|
|
3005
|
+
function paintFunnel(painter, dpr, t, still = false) {
|
|
3006
|
+
const f = funnelLayout.value;
|
|
3007
|
+
const s = funnelSeries.value;
|
|
3008
|
+
if (!s) return;
|
|
3009
|
+
const shown = shownSeries(t);
|
|
3010
|
+
if (!still) lastSeries = shown;
|
|
3011
|
+
const now = shown.find((one) => one.index === s.index) ?? s;
|
|
3012
|
+
s.kind.draw(painter, now, funnelView(f, dpr, isSvg(painter)));
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* A radar's ring values, drawn over the grid and under the series: ringed
|
|
3016
|
+
* with the surface so grid lines don't cut them, while the series' lines
|
|
3017
|
+
* pass over them unbroken.
|
|
3018
|
+
*/
|
|
3019
|
+
function ringValues(painter, r) {
|
|
3020
|
+
if (!r.values.length) return;
|
|
3021
|
+
const ink = resolveColor("var(--dou-chart-label)");
|
|
3022
|
+
const surface = colors.value.surface;
|
|
3023
|
+
if (isSvg(painter)) {
|
|
3024
|
+
painter.raw(r.values.map((tick) => svgText(tick.text, tick.left, tick.top, {
|
|
3025
|
+
color: ink,
|
|
3026
|
+
size: 12,
|
|
3027
|
+
halo: surface
|
|
3028
|
+
})).join(""));
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
const ctx = painter;
|
|
3032
|
+
if (typeof ctx.fillText !== "function") return;
|
|
3033
|
+
const root = rootRef.value;
|
|
3034
|
+
const family = root ? getComputedStyle(root).fontFamily : "sans-serif";
|
|
3035
|
+
ctx.save();
|
|
3036
|
+
ctx.font = `12px ${family}`;
|
|
3037
|
+
ctx.textBaseline = "alphabetic";
|
|
3038
|
+
ctx.lineJoin = "round";
|
|
3039
|
+
ctx.lineWidth = 3;
|
|
3040
|
+
ctx.strokeStyle = surface;
|
|
3041
|
+
ctx.fillStyle = ink;
|
|
3042
|
+
for (const tick of r.values) {
|
|
3043
|
+
const y = tick.top + 8 + 12 * .35;
|
|
3044
|
+
ctx.strokeText(tick.text, tick.left, y);
|
|
3045
|
+
ctx.fillText(tick.text, tick.left, y);
|
|
3046
|
+
}
|
|
3047
|
+
ctx.restore();
|
|
3048
|
+
}
|
|
3049
|
+
const holeRatio = computed(() => Math.min(.95, Math.max(0, pie.value?.props.innerRadius ?? 0)));
|
|
3050
|
+
/** Whether an axis is drawn across the chart (along the bottom or top), so its grid lines stand up. */
|
|
3051
|
+
const across = (axis) => axis.position === "bottom" || axis.position === "top";
|
|
3052
|
+
/** The scales and room a series draws with; `vector` for an SVG, which takes no images. */
|
|
3053
|
+
function viewOf(s, dpr = dprNow(), vector = false) {
|
|
3054
|
+
const r = radarLayout.value;
|
|
3055
|
+
if (r) return radarView(r, dpr, vector);
|
|
3056
|
+
const f = funnelLayout.value;
|
|
3057
|
+
if (f) return funnelView(f, dpr, vector);
|
|
3058
|
+
const l = layout.value;
|
|
3059
|
+
if (!l) return null;
|
|
3060
|
+
const y = l.y[s.axis];
|
|
3061
|
+
if (!y) return null;
|
|
3062
|
+
const horizontal = !!props.horizontal;
|
|
3063
|
+
const { plot } = l;
|
|
3064
|
+
const xScale = l.x.scale;
|
|
3065
|
+
const ys = y.scale;
|
|
3066
|
+
const step = model.value.xStep;
|
|
3067
|
+
return {
|
|
3068
|
+
x: xScale,
|
|
3069
|
+
y: ys,
|
|
3070
|
+
horizontal,
|
|
3071
|
+
band: xScale.kind === "category" ? xScale.step : step > 0 ? step * Math.abs(xScale.k) : (horizontal ? plot.height : plot.width) / 8,
|
|
3072
|
+
point: horizontal ? (x, value) => [ys.map(value), xScale.map(x)] : (x, value) => [xScale.map(x), ys.map(value)],
|
|
3073
|
+
plot,
|
|
3074
|
+
dpr,
|
|
3075
|
+
vector,
|
|
3076
|
+
colors: colors.value
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
/** What a radar's series draw with: a spoke and a value give a point round the center. */
|
|
3080
|
+
function radarView(r, dpr, vector) {
|
|
3081
|
+
const { cx, cy, radius, count, scale } = r;
|
|
3082
|
+
const point = (x, value) => {
|
|
3083
|
+
const angle = spokeAngle(x, count);
|
|
3084
|
+
const out = Math.max(0, scale.map(value));
|
|
3085
|
+
return [cx + Math.sin(angle) * out, cy - Math.cos(angle) * out];
|
|
3086
|
+
};
|
|
3087
|
+
return {
|
|
3088
|
+
x: linearScale([0, count], [0, Math.PI * 2]),
|
|
3089
|
+
y: scale,
|
|
3090
|
+
horizontal: false,
|
|
3091
|
+
band: 0,
|
|
3092
|
+
point,
|
|
3093
|
+
plot: {
|
|
3094
|
+
x: cx - radius,
|
|
3095
|
+
y: cy - radius,
|
|
3096
|
+
width: radius * 2,
|
|
3097
|
+
height: radius * 2
|
|
3098
|
+
},
|
|
3099
|
+
dpr,
|
|
3100
|
+
vector,
|
|
3101
|
+
colors: colors.value,
|
|
3102
|
+
polar: {
|
|
3103
|
+
cx,
|
|
3104
|
+
cy,
|
|
3105
|
+
radius,
|
|
3106
|
+
count
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
/** What a funnel draws with: a stage and a value give the right end of its bar, at its middle. */
|
|
3111
|
+
function funnelView(f, dpr, vector) {
|
|
3112
|
+
const { plot, step, scale } = f;
|
|
3113
|
+
const cx = plot.x + plot.width / 2;
|
|
3114
|
+
return {
|
|
3115
|
+
x: linearScale([0, f.count], [plot.y, plot.y + plot.height]),
|
|
3116
|
+
y: scale,
|
|
3117
|
+
horizontal: false,
|
|
3118
|
+
band: step * FUNNEL_BAR,
|
|
3119
|
+
point: (x, value) => [cx + scale.map(value), plot.y + step * x + step / 2],
|
|
3120
|
+
plot,
|
|
3121
|
+
dpr,
|
|
3122
|
+
vector,
|
|
3123
|
+
colors: colors.value
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
const dprNow = () => typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
|
|
3127
|
+
/** A canvas to draw faded series on, then onto the chart at once: a series sets its own alpha. */
|
|
3128
|
+
let fadeLayer = null;
|
|
3129
|
+
const markerColor = (tone) => markerColors.value[tone];
|
|
3130
|
+
/**
|
|
3131
|
+
* Draws the chart with axes. `still` draws it as it stands, without what
|
|
3132
|
+
* follows the pointer, the legend or a selection: for an SVG.
|
|
3133
|
+
*/
|
|
3134
|
+
function paint(painter, dpr, t, entering, still = false) {
|
|
3135
|
+
const l = layout.value;
|
|
3136
|
+
const { plot } = l;
|
|
3137
|
+
const c = colors.value;
|
|
3138
|
+
const right = plot.x + plot.width;
|
|
3139
|
+
const bottom = plot.y + plot.height;
|
|
3140
|
+
const xLine = !axes.value.x.hidden && !series.value.some((s) => s.kind.categoryY);
|
|
3141
|
+
const horizontal = !!props.horizontal;
|
|
3142
|
+
painter.save();
|
|
3143
|
+
painter.lineWidth = 1;
|
|
3144
|
+
painter.strokeStyle = c.grid;
|
|
3145
|
+
painter.beginPath();
|
|
3146
|
+
for (const axis of [
|
|
3147
|
+
l.x,
|
|
3148
|
+
l.y.y,
|
|
3149
|
+
l.y.y2
|
|
3150
|
+
]) {
|
|
3151
|
+
if (!axis?.grid) continue;
|
|
3152
|
+
for (const tick of axis.ticks) {
|
|
3153
|
+
const at = crisp(axis.scale.map(tick), dpr);
|
|
3154
|
+
if (across(axis)) {
|
|
3155
|
+
if (horizontal && xLine && at <= plot.x + .5) continue;
|
|
3156
|
+
painter.moveTo(at, plot.y);
|
|
3157
|
+
painter.lineTo(at, bottom);
|
|
3158
|
+
} else {
|
|
3159
|
+
if (!horizontal && xLine && at >= bottom - .5) continue;
|
|
3160
|
+
painter.moveTo(plot.x, at);
|
|
3161
|
+
painter.lineTo(right, at);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
painter.stroke();
|
|
3166
|
+
painter.restore();
|
|
3167
|
+
const markers = placedMarkers.value;
|
|
3168
|
+
drawMarkers(painter, markers, markerColor, dpr, true);
|
|
3169
|
+
const xRoom = xWindow.value ? 0 : 4;
|
|
3170
|
+
const yRoom = zoomAxes.value.y && yWindow.value ? 0 : 4;
|
|
3171
|
+
const clip = (target, s) => {
|
|
3172
|
+
target.beginPath();
|
|
3173
|
+
const reveal = s.kind.zero ? 1 : entering;
|
|
3174
|
+
const [ax, ay] = horizontal ? [yRoom, xRoom] : [xRoom, yRoom];
|
|
3175
|
+
const width = plot.width + ax * 2;
|
|
3176
|
+
const height = plot.height + ay * 2;
|
|
3177
|
+
if (horizontal) target.rect(plot.x - ax, plot.y - ay, width, height * reveal);
|
|
3178
|
+
else target.rect(plot.x - ax, plot.y - ay, width * reveal, height);
|
|
3179
|
+
target.clip();
|
|
3180
|
+
};
|
|
3181
|
+
const shown = shownSeries(t);
|
|
3182
|
+
if (!still) lastSeries = shown;
|
|
3183
|
+
const focus = still ? -1 : focused.value ?? pointed.value ?? -1;
|
|
3184
|
+
const fading = focus >= 0 && shown.some((s) => s.index === focus);
|
|
3185
|
+
const vector = isSvg(painter);
|
|
3186
|
+
/** Draws `s` faded, all at once; `false` where it can't be. */
|
|
3187
|
+
const drawFaded = (s, view) => {
|
|
3188
|
+
if (isSvg(painter)) {
|
|
3189
|
+
painter.layer(FADED, () => {
|
|
3190
|
+
painter.save();
|
|
3191
|
+
clip(painter, s);
|
|
3192
|
+
s.kind.draw(painter, s, view);
|
|
3193
|
+
painter.restore();
|
|
3194
|
+
});
|
|
3195
|
+
return true;
|
|
3196
|
+
}
|
|
3197
|
+
const layer = fadeOn(painter, l.width, l.height, dpr);
|
|
3198
|
+
if (!layer) return false;
|
|
3199
|
+
layer.save();
|
|
3200
|
+
clip(layer, s);
|
|
3201
|
+
s.kind.draw(layer, s, view);
|
|
3202
|
+
layer.restore();
|
|
3203
|
+
painter.save();
|
|
3204
|
+
painter.globalAlpha = FADED;
|
|
3205
|
+
painter.drawImage(layer.canvas, 0, 0, l.width, l.height);
|
|
3206
|
+
painter.restore();
|
|
3207
|
+
return true;
|
|
3208
|
+
};
|
|
3209
|
+
const kept = still ? null : dimmed.value;
|
|
3210
|
+
const keep = kept && {
|
|
3211
|
+
x: kept.x <= plot.x ? kept.x - 4 : kept.x,
|
|
3212
|
+
y: kept.y <= plot.y ? kept.y - 4 : kept.y,
|
|
3213
|
+
right: kept.x + kept.width >= right ? right + 4 : kept.x + kept.width,
|
|
3214
|
+
bottom: kept.y + kept.height >= bottom ? bottom + 4 : kept.y + kept.height
|
|
3215
|
+
};
|
|
3216
|
+
for (const s of shown) {
|
|
3217
|
+
const view = viewOf(s, dpr, vector);
|
|
3218
|
+
if (!view) continue;
|
|
3219
|
+
if (fading && s.index !== focus && drawFaded(s, view)) continue;
|
|
3220
|
+
if (keep && !drawFaded(s, view)) continue;
|
|
3221
|
+
painter.save();
|
|
3222
|
+
clip(painter, s);
|
|
3223
|
+
if (keep) {
|
|
3224
|
+
painter.beginPath();
|
|
3225
|
+
painter.rect(keep.x, keep.y, keep.right - keep.x, keep.bottom - keep.y);
|
|
3226
|
+
painter.clip();
|
|
3227
|
+
}
|
|
3228
|
+
s.kind.draw(painter, s, view);
|
|
3229
|
+
painter.restore();
|
|
3230
|
+
}
|
|
3231
|
+
painter.save();
|
|
3232
|
+
painter.lineWidth = 1;
|
|
3233
|
+
painter.strokeStyle = c.baseline;
|
|
3234
|
+
painter.beginPath();
|
|
3235
|
+
if (xLine) {
|
|
3236
|
+
if (horizontal) {
|
|
3237
|
+
const x = crisp(plot.x, dpr);
|
|
3238
|
+
painter.moveTo(x, plot.y);
|
|
3239
|
+
painter.lineTo(x, bottom);
|
|
3240
|
+
} else {
|
|
3241
|
+
const y = crisp(bottom, dpr);
|
|
3242
|
+
painter.moveTo(plot.x, y);
|
|
3243
|
+
painter.lineTo(right, y);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
const primary = l.y.y ?? l.y.y2;
|
|
3247
|
+
const listed = model.value.yCategories[l.y.y ? "y" : "y2"];
|
|
3248
|
+
if (primary && primary.scale.kind === "linear" && !listed) {
|
|
3249
|
+
const [lo, hi] = primary.scale.domain;
|
|
3250
|
+
if (lo < 0 && hi > 0) {
|
|
3251
|
+
const at = crisp(primary.scale.map(0), dpr);
|
|
3252
|
+
if (horizontal) {
|
|
3253
|
+
painter.moveTo(at, plot.y);
|
|
3254
|
+
painter.lineTo(at, bottom);
|
|
3255
|
+
} else {
|
|
3256
|
+
painter.moveTo(plot.x, at);
|
|
3257
|
+
painter.lineTo(right, at);
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
painter.stroke();
|
|
3262
|
+
painter.restore();
|
|
3263
|
+
drawMarkers(painter, markers, markerColor, dpr, false);
|
|
3264
|
+
}
|
|
3265
|
+
/** A clear canvas the size of the chart to draw a faded series on; `null` where there is none. */
|
|
3266
|
+
function fadeOn(main, width, height, dpr) {
|
|
3267
|
+
if (typeof document === "undefined") return null;
|
|
3268
|
+
fadeLayer ??= document.createElement("canvas");
|
|
3269
|
+
if (fadeLayer.width !== main.canvas.width) fadeLayer.width = main.canvas.width;
|
|
3270
|
+
if (fadeLayer.height !== main.canvas.height) fadeLayer.height = main.canvas.height;
|
|
3271
|
+
const ctx = fadeLayer.getContext("2d");
|
|
3272
|
+
if (!ctx) return null;
|
|
3273
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
3274
|
+
ctx.clearRect(0, 0, width, height);
|
|
3275
|
+
return ctx;
|
|
3276
|
+
}
|
|
3277
|
+
/** A heatmap's values, as its `format` writes them. */
|
|
3278
|
+
function heatText(s) {
|
|
3279
|
+
return s.props.format ?? numberFormatFull(locale.value.name);
|
|
3280
|
+
}
|
|
3281
|
+
/** The category down the value axis at `place` (a heatmap's row), as the axis writes it. */
|
|
3282
|
+
function yCategoryText(axis, place) {
|
|
3283
|
+
const label = model.value.yCategories[axis]?.[place] ?? "";
|
|
3284
|
+
const own = axes.value[axis]?.format;
|
|
3285
|
+
return own ? own(label) : label;
|
|
3286
|
+
}
|
|
3287
|
+
/** A change as text, with `+` before a rise (a fall has its minus already). */
|
|
3288
|
+
const signed = (text, value) => value > 0 ? `+${text(value)}` : text(value);
|
|
3289
|
+
/** Values as their axis shows them. */
|
|
3290
|
+
function valueText(axis) {
|
|
3291
|
+
return axes.value[axis]?.format ?? numberFormatFull(locale.value.name);
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* A series' value at `row` as the tooltip writes it: a band's two ends, a
|
|
3295
|
+
* share of a whole stack with its value, or the value as its axis shows it.
|
|
3296
|
+
*/
|
|
3297
|
+
function seriesText(s, row) {
|
|
3298
|
+
const y = s.y[row];
|
|
3299
|
+
if (s.kind.categoryY) return heatText(s)(y);
|
|
3300
|
+
if (s.kind.box) {
|
|
3301
|
+
const box = boxes(s)[row];
|
|
3302
|
+
if (!box) return "";
|
|
3303
|
+
const text = valueText(s.axis);
|
|
3304
|
+
const m = messages.value;
|
|
3305
|
+
const line = format(m.box, {
|
|
3306
|
+
median: text(box.median),
|
|
3307
|
+
q1: text(box.q1),
|
|
3308
|
+
q3: text(box.q3),
|
|
3309
|
+
low: text(box.low),
|
|
3310
|
+
high: text(box.high)
|
|
3311
|
+
});
|
|
3312
|
+
if (!box.outliers.length) return line;
|
|
3313
|
+
return `${line}${m.itemSeparator}${format(m.boxOutliers, { count: box.outliers.length })} ${box.outliers.map(text).join(m.nameSeparator)}`;
|
|
3314
|
+
}
|
|
3315
|
+
if (s.kind.waterfall) {
|
|
3316
|
+
const text = valueText(s.axis);
|
|
3317
|
+
if (isTotal(s, row)) return text(y);
|
|
3318
|
+
return format(messages.value.waterfallStep, {
|
|
3319
|
+
change: signed(text, y),
|
|
3320
|
+
total: text(s.top[row])
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
if (s.base && !s.stackEnds) {
|
|
3324
|
+
const text = valueText(s.axis);
|
|
3325
|
+
return format(messages.value.band, {
|
|
3326
|
+
from: text(s.base[row]),
|
|
3327
|
+
to: text(y)
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
if (s.stackEnds && axes.value[s.axis] && declaredAxes.value[s.axis]?.percent) return format(messages.value.share, {
|
|
3331
|
+
value: numberFormatFull(locale.value.name)(y),
|
|
3332
|
+
share: percent.value.format(Math.abs(s.top[row] - s.base[row]))
|
|
3333
|
+
});
|
|
3334
|
+
return valueText(s.axis)(y);
|
|
3335
|
+
}
|
|
3336
|
+
/** An x as the axis shows it, in full. */
|
|
3337
|
+
function xText(value) {
|
|
3338
|
+
const m0 = model.value;
|
|
3339
|
+
const name = locale.value.name;
|
|
3340
|
+
const xFormat = axes.value.x.format;
|
|
3341
|
+
if (m0.xKind === "category") {
|
|
3342
|
+
const category = m0.categories[value] ?? "";
|
|
3343
|
+
return xFormat ? xFormat(category) : category;
|
|
3344
|
+
}
|
|
3345
|
+
if (m0.xKind === "time") {
|
|
3346
|
+
if (xFormat) return xFormat(new Date(value));
|
|
3347
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
3348
|
+
for (const s of series.value) gap = Math.min(gap, neighborGap(s.x.values, value));
|
|
3349
|
+
const { unit, digits } = timePrecision(value, gap);
|
|
3350
|
+
return timeFormatFull(name, unit, digits)(value);
|
|
3351
|
+
}
|
|
3352
|
+
if (xFormat) return xFormat(value);
|
|
3353
|
+
return m0.xIntegers && m0.x ? numberTickFormat(name, m0.x, { years: true })(value) : numberFormatFull(name)(value);
|
|
3354
|
+
}
|
|
3355
|
+
const label = computed(() => {
|
|
3356
|
+
if (props.ariaLabel) return props.ariaLabel;
|
|
3357
|
+
const m = messages.value;
|
|
3358
|
+
const name = locale.value.name;
|
|
3359
|
+
const m0 = model.value;
|
|
3360
|
+
if (empty.value) return format(m.summary, {
|
|
3361
|
+
chart: m.chart,
|
|
3362
|
+
series: m.empty
|
|
3363
|
+
});
|
|
3364
|
+
const td = tree.value && treeData.value;
|
|
3365
|
+
if (td) {
|
|
3366
|
+
const value = numberFormatFull(name);
|
|
3367
|
+
const list = td.root.children.map((node) => format(m.slice, {
|
|
3368
|
+
label: node.name,
|
|
3369
|
+
value: value(node.value),
|
|
3370
|
+
share: percent.value.format(node.value / td.root.value)
|
|
3371
|
+
})).join(m.listSeparator);
|
|
3372
|
+
const title = tree.value.props.name;
|
|
3373
|
+
return withKeys(format(m.summary, {
|
|
3374
|
+
chart: m.chart,
|
|
3375
|
+
series: title ? format(m.pie, {
|
|
3376
|
+
name: title,
|
|
3377
|
+
slices: list
|
|
3378
|
+
}) : list
|
|
3379
|
+
}));
|
|
3380
|
+
}
|
|
3381
|
+
const gauge = pie.value?.gauge?.();
|
|
3382
|
+
if (gauge) {
|
|
3383
|
+
const text = numberFormatFull(name);
|
|
3384
|
+
return format(m.summary, {
|
|
3385
|
+
chart: m.chart,
|
|
3386
|
+
series: format(pie.value.props.name ? m.gauge : m.gaugeValue, {
|
|
3387
|
+
name: pie.value.props.name ?? "",
|
|
3388
|
+
value: gauge.text,
|
|
3389
|
+
min: text(gauge.min),
|
|
3390
|
+
max: text(gauge.max)
|
|
3391
|
+
})
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
if (pie.value) {
|
|
3395
|
+
const value = numberFormatFull(name);
|
|
3396
|
+
const list = shownSlices.value.map((slice) => format(m.slice, {
|
|
3397
|
+
label: slice.label,
|
|
3398
|
+
value: value(slice.value),
|
|
3399
|
+
share: percent.value.format(slice.share)
|
|
3400
|
+
})).join(m.listSeparator);
|
|
3401
|
+
const off = slices.value.length - shownSlices.value.length;
|
|
3402
|
+
const shown = off ? `${list}${m.itemSeparator}${format(m.hiddenSlices, { count: off })}` : list;
|
|
3403
|
+
const title = pie.value.props.name;
|
|
3404
|
+
return withKeys(format(m.summary, {
|
|
3405
|
+
chart: m.chart,
|
|
3406
|
+
series: title ? format(m.pie, {
|
|
3407
|
+
name: title,
|
|
3408
|
+
slices: shown
|
|
3409
|
+
}) : shown
|
|
3410
|
+
}));
|
|
3411
|
+
}
|
|
3412
|
+
const fs = funnelSeries.value;
|
|
3413
|
+
if (fs) {
|
|
3414
|
+
const stages = funnelStages.value.flatMap((stage, k) => {
|
|
3415
|
+
if (stage.row < 0) return [];
|
|
3416
|
+
const value = seriesText(fs, stage.row);
|
|
3417
|
+
return stage.step === null ? [`${xText(k)} ${value}`] : [format(m.slice, {
|
|
3418
|
+
label: xText(k),
|
|
3419
|
+
value,
|
|
3420
|
+
share: `${m.stageRate} ${rate.value.format(stage.step)}`
|
|
3421
|
+
})];
|
|
3422
|
+
}).join(m.listSeparator);
|
|
3423
|
+
return withKeys(format(m.summary, {
|
|
3424
|
+
chart: m.chart,
|
|
3425
|
+
series: format(m.pie, {
|
|
3426
|
+
name: fs.name,
|
|
3427
|
+
slices: stages
|
|
3428
|
+
})
|
|
3429
|
+
}));
|
|
3430
|
+
}
|
|
3431
|
+
const count = numberFormatFull(name);
|
|
3432
|
+
const parts = m0.series.map((s) => {
|
|
3433
|
+
if (s.input.kind.categoryY) return heatSummary(s);
|
|
3434
|
+
let range = s.input.kind.box ? extent(s.top) : extent(s.y);
|
|
3435
|
+
const low = s.base && !s.stackEnds ? extent(s.base) : null;
|
|
3436
|
+
if (range && low) range = [Math.min(range[0], low[0]), Math.max(range[1], low[1])];
|
|
3437
|
+
if (!range) return format(m.seriesEmpty, { name: s.name });
|
|
3438
|
+
let points = 0;
|
|
3439
|
+
for (let i = 0; i < s.y.length; i++) if (s.y[i] === s.y[i]) points++;
|
|
3440
|
+
const text = s.stackEnds && declaredAxes.value[s.axis]?.percent ? numberFormatFull(name) : valueText(s.axis);
|
|
3441
|
+
const message = s.input.kind.box ? m.boxSeries : s.input.kind.xTitle ? m.binsSeries : m.series;
|
|
3442
|
+
return format(message, {
|
|
3443
|
+
name: s.name,
|
|
3444
|
+
count: count(points),
|
|
3445
|
+
min: text(range[0]),
|
|
3446
|
+
max: text(range[1])
|
|
3447
|
+
});
|
|
3448
|
+
});
|
|
3449
|
+
let chart = m.chart;
|
|
3450
|
+
const bins = m0.series.find((s) => s.input.kind.xTitle)?.rows;
|
|
3451
|
+
if (bins?.length) chart = format(props.horizontal ? m.rangeDown : m.range, {
|
|
3452
|
+
chart: m.chart,
|
|
3453
|
+
from: xText(bins[0].x0),
|
|
3454
|
+
to: xText(bins[bins.length - 1].x1)
|
|
3455
|
+
});
|
|
3456
|
+
else if (m0.x && !radar.value) chart = format(props.horizontal ? m.rangeDown : m.range, {
|
|
3457
|
+
chart: m.chart,
|
|
3458
|
+
from: xText(m0.x[0]),
|
|
3459
|
+
to: xText(m0.x[1])
|
|
3460
|
+
});
|
|
3461
|
+
return withKeys(format(m.summary, {
|
|
3462
|
+
chart,
|
|
3463
|
+
series: parts.join(m.listSeparator)
|
|
3464
|
+
}));
|
|
3465
|
+
});
|
|
3466
|
+
/** What is read out for a heatmap: its size, its range, and where its largest value is. */
|
|
3467
|
+
function heatSummary(s) {
|
|
3468
|
+
const m = messages.value;
|
|
3469
|
+
const count = numberFormatFull(locale.value.name);
|
|
3470
|
+
let top = -1;
|
|
3471
|
+
for (let i = 0; i < s.y.length; i++) if (s.y[i] === s.y[i] && (top < 0 || s.y[i] > s.y[top])) top = i;
|
|
3472
|
+
const range = extent(s.y);
|
|
3473
|
+
if (!range || top < 0) return format(m.seriesEmpty, { name: s.name });
|
|
3474
|
+
const text = heatText(s.input);
|
|
3475
|
+
return format(m.heatmap, {
|
|
3476
|
+
name: s.name,
|
|
3477
|
+
columns: count(allX([s.x.values]).length),
|
|
3478
|
+
rows: count(model.value.yCategories[s.axis]?.length ?? 0),
|
|
3479
|
+
min: text(range[0]),
|
|
3480
|
+
max: text(range[1]),
|
|
3481
|
+
at: `${yCategoryText(s.axis, s.top[top])}${m.itemSeparator}${xText(s.x.values[top])}`
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
/** The most rows the table shows: more would take long to lay out, and to read. */
|
|
3485
|
+
const tableCells = computed(() => {
|
|
3486
|
+
if (!props.table || empty.value) return {
|
|
3487
|
+
head: [],
|
|
3488
|
+
rows: []
|
|
3489
|
+
};
|
|
3490
|
+
const m = messages.value;
|
|
3491
|
+
const td = tree.value && treeData.value;
|
|
3492
|
+
if (td) {
|
|
3493
|
+
const value = numberFormatFull(locale.value.name);
|
|
3494
|
+
return {
|
|
3495
|
+
head: [
|
|
3496
|
+
m.tableNode,
|
|
3497
|
+
tree.value.props.name ?? m.tableValue,
|
|
3498
|
+
m.tableShare
|
|
3499
|
+
],
|
|
3500
|
+
rows: td.nodes.filter((node) => node.depth > 0).map((node) => [
|
|
3501
|
+
treePath(node).join(" / "),
|
|
3502
|
+
value(node.value),
|
|
3503
|
+
percent.value.format(node.value / td.root.value)
|
|
3504
|
+
])
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
const gauge = pie.value?.gauge?.();
|
|
3508
|
+
if (gauge) return {
|
|
3509
|
+
head: [pie.value.props.name ?? m.tableValue],
|
|
3510
|
+
rows: [[gauge.text]]
|
|
3511
|
+
};
|
|
3512
|
+
if (pie.value) {
|
|
3513
|
+
const value = numberFormatFull(locale.value.name);
|
|
3514
|
+
return {
|
|
3515
|
+
head: [
|
|
3516
|
+
typeof (pie.value.props.label ?? props.x) === "string" ? String(pie.value.props.label ?? props.x) : "",
|
|
3517
|
+
pie.value.props.name ?? m.tableValue,
|
|
3518
|
+
m.tableShare
|
|
3519
|
+
],
|
|
3520
|
+
rows: shownSlices.value.map((slice) => [
|
|
3521
|
+
slice.label,
|
|
3522
|
+
value(slice.value),
|
|
3523
|
+
percent.value.format(slice.share)
|
|
3524
|
+
])
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
const list = series.value;
|
|
3528
|
+
const xHead = axes.value.x.title ?? (typeof props.x === "string" ? props.x : "");
|
|
3529
|
+
const fs = funnelSeries.value;
|
|
3530
|
+
if (fs) return {
|
|
3531
|
+
head: [
|
|
3532
|
+
xHead,
|
|
3533
|
+
fs.name,
|
|
3534
|
+
m.stageRate,
|
|
3535
|
+
m.totalRate
|
|
3536
|
+
],
|
|
3537
|
+
rows: funnelStages.value.flatMap((stage, k) => stage.row < 0 ? [] : [[
|
|
3538
|
+
xText(k),
|
|
3539
|
+
seriesText(fs, stage.row),
|
|
3540
|
+
stage.step === null ? "" : rate.value.format(stage.step),
|
|
3541
|
+
stage.total === null ? "" : rate.value.format(stage.total)
|
|
3542
|
+
]])
|
|
3543
|
+
};
|
|
3544
|
+
const heat = list.find((s) => s.kind.categoryY);
|
|
3545
|
+
if (heat) {
|
|
3546
|
+
const xs = allX([heat.x.values]);
|
|
3547
|
+
const down = model.value.yCategories[heat.axis] ?? [];
|
|
3548
|
+
const cells = down.map(() => xs.map(() => ""));
|
|
3549
|
+
const text = heatText(heat);
|
|
3550
|
+
for (let i = 0; i < heat.y.length; i++) {
|
|
3551
|
+
const at = xs.indexOf(heat.x.values[i]);
|
|
3552
|
+
const row = cells[heat.top[i]];
|
|
3553
|
+
if (at >= 0 && row && heat.y[i] === heat.y[i]) row[at] = text(heat.y[i]);
|
|
3554
|
+
}
|
|
3555
|
+
return {
|
|
3556
|
+
head: [axes.value[heat.axis]?.title ?? "", ...xs.map((x) => xText(x))],
|
|
3557
|
+
rows: down.map((_, k) => [yCategoryText(heat.axis, k), ...cells[k]])
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
if (list.some((s) => s.kind.tooltip === "item")) {
|
|
3561
|
+
const rows = [];
|
|
3562
|
+
for (const s of list) for (let i = 0; i < s.y.length; i++) {
|
|
3563
|
+
const x = s.x.values[i];
|
|
3564
|
+
if (x !== x || s.y[i] !== s.y[i]) continue;
|
|
3565
|
+
rows.push([
|
|
3566
|
+
s.name,
|
|
3567
|
+
xText(x),
|
|
3568
|
+
seriesText(s, i)
|
|
3569
|
+
]);
|
|
3570
|
+
}
|
|
3571
|
+
return {
|
|
3572
|
+
head: [
|
|
3573
|
+
m.tableSeries,
|
|
3574
|
+
xHead,
|
|
3575
|
+
axes.value.y?.title ?? m.tableValue
|
|
3576
|
+
],
|
|
3577
|
+
rows
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
const xs = allX(list.map((s) => s.x.values));
|
|
3581
|
+
const binned = list.find((s) => s.kind.xTitle);
|
|
3582
|
+
const xCell = (x) => {
|
|
3583
|
+
const row = binned ? rowAt(binned.x.values, x) : -1;
|
|
3584
|
+
return binned && row >= 0 ? binned.kind.xTitle(binned, row, xText) : xText(x);
|
|
3585
|
+
};
|
|
3586
|
+
const counted = binned?.props.value;
|
|
3587
|
+
return {
|
|
3588
|
+
head: [xHead || (typeof counted === "string" ? counted : ""), ...list.map((s) => withUnit(s.name, axes.value[s.axis]?.title))],
|
|
3589
|
+
rows: xs.map((x) => [xCell(x), ...list.map((s) => {
|
|
3590
|
+
const row = rowAt(s.x.values, x);
|
|
3591
|
+
return row < 0 || s.y[row] !== s.y[row] ? "" : seriesText(s, row);
|
|
3592
|
+
})])
|
|
3593
|
+
};
|
|
3594
|
+
});
|
|
3595
|
+
/** `name` with the unit at the end of `title` (in brackets), if it hasn't one already. */
|
|
3596
|
+
function withUnit(name, title) {
|
|
3597
|
+
const unit = title && /\s*[((][^()()]+[))]\s*$/.exec(title)?.[0].trim();
|
|
3598
|
+
return unit && !name.endsWith(unit) ? `${name}${unit}` : name;
|
|
3599
|
+
}
|
|
3600
|
+
/** The table's caption: the chart's name, else what its series are. */
|
|
3601
|
+
const tableCaption = computed(() => {
|
|
3602
|
+
if (props.ariaLabel) return props.ariaLabel;
|
|
3603
|
+
const m = messages.value;
|
|
3604
|
+
const names = pie.value ? [pie.value.props.name].filter((name) => !!name) : series.value.map((s) => s.name);
|
|
3605
|
+
return names.length ? format(m.tableOf, { names: names.join(m.nameSeparator) }) : m.table;
|
|
3606
|
+
});
|
|
3607
|
+
const table = {
|
|
3608
|
+
shown: computed(() => !!props.table && !fromServer.value),
|
|
3609
|
+
caption: tableCaption,
|
|
3610
|
+
head: computed(() => tableCells.value.head),
|
|
3611
|
+
rows: computed(() => tableCells.value.rows.slice(0, TABLE_ROWS)),
|
|
3612
|
+
more: computed(() => {
|
|
3613
|
+
const count = tableCells.value.rows.length;
|
|
3614
|
+
if (count <= TABLE_ROWS) return "";
|
|
3615
|
+
const number = numberFormatFull(locale.value.name);
|
|
3616
|
+
return format(messages.value.tableMore, {
|
|
3617
|
+
shown: number(TABLE_ROWS),
|
|
3618
|
+
count: number(count)
|
|
3619
|
+
});
|
|
3620
|
+
}),
|
|
3621
|
+
close: () => {
|
|
3622
|
+
emit("update:table", false);
|
|
3623
|
+
rootRef.value?.focus();
|
|
3624
|
+
}
|
|
3625
|
+
};
|
|
3626
|
+
/** The summary, and how to explore it when the chart can be. */
|
|
3627
|
+
function withKeys(summary) {
|
|
3628
|
+
if (!interactive.value) return summary;
|
|
3629
|
+
return format(zoomOn.value ? messages.value.zoomKeys : messages.value.keys, { summary });
|
|
3630
|
+
}
|
|
3631
|
+
const labels = computed(() => {
|
|
3632
|
+
const f = funnelLayout.value;
|
|
3633
|
+
if (f) return [
|
|
3634
|
+
...f.names.map((tick, i) => ({
|
|
3635
|
+
...tick,
|
|
3636
|
+
key: `stage:${i}`,
|
|
3637
|
+
axis: "stage"
|
|
3638
|
+
})),
|
|
3639
|
+
...f.values.map((tick, i) => ({
|
|
3640
|
+
...tick,
|
|
3641
|
+
key: `value:${i}`,
|
|
3642
|
+
axis: "value"
|
|
3643
|
+
})),
|
|
3644
|
+
...f.rates.map((tick, i) => ({
|
|
3645
|
+
...tick,
|
|
3646
|
+
key: `rate:${i}`,
|
|
3647
|
+
axis: "rate"
|
|
3648
|
+
}))
|
|
3649
|
+
];
|
|
3650
|
+
const r = radarLayout.value;
|
|
3651
|
+
if (r) return r.labels.map((tick, i) => ({
|
|
3652
|
+
...tick,
|
|
3653
|
+
key: `spoke:${i}`,
|
|
3654
|
+
axis: "spoke"
|
|
3655
|
+
}));
|
|
3656
|
+
const l = layout.value;
|
|
3657
|
+
if (!l) return [];
|
|
3658
|
+
const out = [];
|
|
3659
|
+
for (const axis of [
|
|
3660
|
+
l.x,
|
|
3661
|
+
l.y.y,
|
|
3662
|
+
l.y.y2
|
|
3663
|
+
]) {
|
|
3664
|
+
if (!axis) continue;
|
|
3665
|
+
axis.labels.forEach((tick, i) => out.push({
|
|
3666
|
+
...tick,
|
|
3667
|
+
key: `${axis.position}:${i}`,
|
|
3668
|
+
axis: axis.position
|
|
3669
|
+
}));
|
|
3670
|
+
}
|
|
3671
|
+
return out;
|
|
3672
|
+
});
|
|
3673
|
+
const legend = computed(() => {
|
|
3674
|
+
const l = layout.value ?? pieLayout.value ?? treeLayout.value ?? radarLayout.value ?? funnelLayout.value;
|
|
3675
|
+
if (!l) return [];
|
|
3676
|
+
return l.legend.map((item) => ({
|
|
3677
|
+
...item,
|
|
3678
|
+
...legendEntries.value[item.index]
|
|
3679
|
+
}));
|
|
3680
|
+
});
|
|
3681
|
+
const titles = computed(() => {
|
|
3682
|
+
const l = layout.value;
|
|
3683
|
+
if (!l) return [];
|
|
3684
|
+
return [
|
|
3685
|
+
l.x,
|
|
3686
|
+
l.y.y,
|
|
3687
|
+
l.y.y2
|
|
3688
|
+
].flatMap((axis) => axis?.title ? [{
|
|
3689
|
+
key: axis.position,
|
|
3690
|
+
...axis.title
|
|
3691
|
+
}] : []);
|
|
3692
|
+
});
|
|
3693
|
+
/** The size of what is drawn: a chart with axes, or a pie. */
|
|
3694
|
+
const frame = computed(() => layout.value ?? pieLayout.value ?? treeLayout.value ?? radarLayout.value ?? funnelLayout.value);
|
|
3695
|
+
const overlay = {
|
|
3696
|
+
shown: computed(() => !!frame.value && !empty.value && !fromServer.value),
|
|
3697
|
+
labels,
|
|
3698
|
+
titles,
|
|
3699
|
+
legend,
|
|
3700
|
+
legendBox: computed(() => (layout.value ?? pieLayout.value ?? treeLayout.value ?? radarLayout.value ?? funnelLayout.value)?.legendBox),
|
|
3701
|
+
hole: computed(() => {
|
|
3702
|
+
const tl = treeLayout.value;
|
|
3703
|
+
if (tl && tree.value?.shape === "sunburst" && tl.inner > 0 && !empty.value && !fromServer.value) return {
|
|
3704
|
+
left: tl.cx,
|
|
3705
|
+
top: tl.cy,
|
|
3706
|
+
width: tl.inner * 2 * .8
|
|
3707
|
+
};
|
|
3708
|
+
const l = pieLayout.value;
|
|
3709
|
+
if (!l || empty.value || fromServer.value || !(holeRatio.value > 0)) return null;
|
|
3710
|
+
const box = arcBox(pieArc.value[0], pieArc.value[1]);
|
|
3711
|
+
return {
|
|
3712
|
+
left: l.cx,
|
|
3713
|
+
top: l.cy,
|
|
3714
|
+
width: l.radius * holeRatio.value * 2 * .8,
|
|
3715
|
+
above: box.bottom < .01
|
|
3716
|
+
};
|
|
3717
|
+
}),
|
|
3718
|
+
center: () => pie.value?.center() ?? tree.value?.center?.(),
|
|
3719
|
+
toggle,
|
|
3720
|
+
isolate,
|
|
3721
|
+
hint: computed(() => messages.value.legendHint),
|
|
3722
|
+
focus: (index) => focused.value = index,
|
|
3723
|
+
data: dataLabels,
|
|
3724
|
+
dataShown: computed(() => labelsShown.value),
|
|
3725
|
+
dataFocus: computed(() => pie.value ? null : focused.value ?? pointed.value),
|
|
3726
|
+
markers: markerLabels,
|
|
3727
|
+
empty: computed(() => empty.value && !props.loading),
|
|
3728
|
+
emptyContent: () => slots.empty?.() ?? messages.value.empty,
|
|
3729
|
+
loading: computed(() => !!props.loading)
|
|
3730
|
+
};
|
|
3731
|
+
const stripState = computed(() => {
|
|
3732
|
+
const l = layout.value;
|
|
3733
|
+
if (!l?.strip || !rangePart.value) return null;
|
|
3734
|
+
const [f0, f1] = l.x.full;
|
|
3735
|
+
const [d0, d1] = l.x.scale.domain;
|
|
3736
|
+
const span = f1 - f0 || 1;
|
|
3737
|
+
return {
|
|
3738
|
+
rect: l.strip,
|
|
3739
|
+
horizontal: !!props.horizontal,
|
|
3740
|
+
from: Math.max(0, (d0 - f0) / span),
|
|
3741
|
+
to: Math.min(1, (d1 - f0) / span)
|
|
3742
|
+
};
|
|
3743
|
+
});
|
|
3744
|
+
/**
|
|
3745
|
+
* What the strip's sketch is drawn from: its box, all of x and each y axis'
|
|
3746
|
+
* whole span. Kept the same object while they stay, so panning, which only
|
|
3747
|
+
* moves the shown part, doesn't draw the sketch again.
|
|
3748
|
+
*/
|
|
3749
|
+
const sameWindow = (a, b) => a[0] === b[0] && a[1] === b[1];
|
|
3750
|
+
const stripSketch = computed((old) => {
|
|
3751
|
+
const l = layout.value;
|
|
3752
|
+
if (!l?.strip || !rangePart.value) return null;
|
|
3753
|
+
const next = {
|
|
3754
|
+
rect: l.strip,
|
|
3755
|
+
x: l.x.full,
|
|
3756
|
+
y: {}
|
|
3757
|
+
};
|
|
3758
|
+
for (const [name, axis] of Object.entries(l.y)) if (axis) next.y[name] = {
|
|
3759
|
+
full: axis.full,
|
|
3760
|
+
log: axis.scale.kind === "log"
|
|
3761
|
+
};
|
|
3762
|
+
if (old && old.rect.x === next.rect.x && old.rect.y === next.rect.y && old.rect.width === next.rect.width && old.rect.height === next.rect.height && sameWindow(old.x, next.x) && Object.keys(old.y).length === Object.keys(next.y).length && Object.entries(next.y).every(([name, axis]) => {
|
|
3763
|
+
const was = old.y[name];
|
|
3764
|
+
return !!was && was.log === axis.log && sameWindow(was.full, axis.full);
|
|
3765
|
+
})) return old;
|
|
3766
|
+
return next;
|
|
3767
|
+
});
|
|
3768
|
+
/** Draws every series shown on the strip, in one quiet ink, over all of x. */
|
|
3769
|
+
function paintStrip(canvas) {
|
|
3770
|
+
const sketch = stripSketch.value;
|
|
3771
|
+
if (!sketch) return;
|
|
3772
|
+
const strip = sketch.rect;
|
|
3773
|
+
themeVersion.value;
|
|
3774
|
+
const dpr = dprNow();
|
|
3775
|
+
const width = Math.round(strip.width * dpr);
|
|
3776
|
+
const height = Math.round(strip.height * dpr);
|
|
3777
|
+
if (canvas.width !== width) canvas.width = width;
|
|
3778
|
+
if (canvas.height !== height) canvas.height = height;
|
|
3779
|
+
canvas.style.width = `${strip.width}px`;
|
|
3780
|
+
canvas.style.height = `${strip.height}px`;
|
|
3781
|
+
const ctx = canvas.getContext("2d");
|
|
3782
|
+
if (!ctx) return;
|
|
3783
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
3784
|
+
ctx.clearRect(0, 0, strip.width, strip.height);
|
|
3785
|
+
drawStrip(ctx, sketch, dpr);
|
|
3786
|
+
}
|
|
3787
|
+
/** The strip's sketch, in the strip's own px. */
|
|
3788
|
+
function drawStrip(ctx, sketch, dpr) {
|
|
3789
|
+
const strip = sketch.rect;
|
|
3790
|
+
const horizontal = !!props.horizontal;
|
|
3791
|
+
const pad = 4;
|
|
3792
|
+
const plot = {
|
|
3793
|
+
x: 0,
|
|
3794
|
+
y: 0,
|
|
3795
|
+
width: strip.width,
|
|
3796
|
+
height: strip.height
|
|
3797
|
+
};
|
|
3798
|
+
const along = horizontal ? [0, strip.height] : [0, strip.width];
|
|
3799
|
+
const m = model.value;
|
|
3800
|
+
const x = m.xKind === "category" ? categoryScale(m.categories, along) : linearScale(sketch.x, along, m.xKind === "time" ? "time" : "linear");
|
|
3801
|
+
const ink = resolveColor("var(--dou-chart-range-ink)");
|
|
3802
|
+
const length = horizontal ? strip.height : strip.width;
|
|
3803
|
+
for (const s of series.value) {
|
|
3804
|
+
const axis = sketch.y[s.axis];
|
|
3805
|
+
if (!axis) continue;
|
|
3806
|
+
const span = horizontal ? [pad, strip.width - pad] : [strip.height - pad, pad];
|
|
3807
|
+
const data = m.y[s.axis] ?? axis.full;
|
|
3808
|
+
const ends = m.zero[s.axis] ? [Math.min(0, data[0]), Math.max(0, data[1])] : [data[0], data[1] > data[0] ? data[1] : data[0] + 1];
|
|
3809
|
+
const y = axis.log ? logScale(axis.full, span) : linearScale(ends, span);
|
|
3810
|
+
const step = m.xStep;
|
|
3811
|
+
const view = {
|
|
3812
|
+
x,
|
|
3813
|
+
y,
|
|
3814
|
+
horizontal,
|
|
3815
|
+
band: x.kind === "category" ? x.step : step > 0 ? step * Math.abs(x.k) : length / 8,
|
|
3816
|
+
point: horizontal ? (v, value) => [y.map(value), x.map(v)] : (v, value) => [x.map(v), y.map(value)],
|
|
3817
|
+
plot,
|
|
3818
|
+
dpr,
|
|
3819
|
+
vector: isSvg(ctx),
|
|
3820
|
+
colors: colors.value
|
|
3821
|
+
};
|
|
3822
|
+
ctx.save();
|
|
3823
|
+
ctx.beginPath();
|
|
3824
|
+
ctx.rect(0, 0, strip.width, strip.height);
|
|
3825
|
+
ctx.clip();
|
|
3826
|
+
s.kind.draw(ctx, {
|
|
3827
|
+
...s,
|
|
3828
|
+
color: ink,
|
|
3829
|
+
props: {
|
|
3830
|
+
...s.props,
|
|
3831
|
+
dots: false,
|
|
3832
|
+
lineWidth: 1
|
|
3833
|
+
}
|
|
3834
|
+
}, view);
|
|
3835
|
+
ctx.restore();
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
const zoomLayer = {
|
|
3839
|
+
brush: computed(() => brushDraft.value ?? selection.value),
|
|
3840
|
+
strip: stripState,
|
|
3841
|
+
sketch: stripSketch,
|
|
3842
|
+
least: computed(() => {
|
|
3843
|
+
const l = layout.value;
|
|
3844
|
+
if (!l) return 0;
|
|
3845
|
+
const span = l.x.full[1] - l.x.full[0];
|
|
3846
|
+
return span > 0 ? Math.min(1, leastX.value / span) : 1;
|
|
3847
|
+
}),
|
|
3848
|
+
step: computed(() => {
|
|
3849
|
+
const l = layout.value;
|
|
3850
|
+
const m = model.value;
|
|
3851
|
+
if (!l || m.xKind !== "category") return .01;
|
|
3852
|
+
return 1 / Math.max(1, l.x.full[1] - l.x.full[0]);
|
|
3853
|
+
}),
|
|
3854
|
+
texts: computed(() => {
|
|
3855
|
+
const l = layout.value;
|
|
3856
|
+
if (!l) return ["", ""];
|
|
3857
|
+
const m = model.value;
|
|
3858
|
+
const [from, to] = l.x.scale.domain;
|
|
3859
|
+
if (m.xKind === "category") {
|
|
3860
|
+
const [a, b] = windowToInterval([from, to], m.xKind, m.categories);
|
|
3861
|
+
return [String(a), String(b)];
|
|
3862
|
+
}
|
|
3863
|
+
return [xText(from), xText(to)];
|
|
3864
|
+
}),
|
|
3865
|
+
labels: computed(() => [messages.value.rangeStart, messages.value.rangeEnd]),
|
|
3866
|
+
show: (from, to, settle) => {
|
|
3867
|
+
const l = layout.value;
|
|
3868
|
+
if (!l) return;
|
|
3869
|
+
const [f0, f1] = l.x.full;
|
|
3870
|
+
const span = f1 - f0;
|
|
3871
|
+
showX([f0 + from * span, f0 + to * span], settle);
|
|
3872
|
+
},
|
|
3873
|
+
reset: () => showX(null),
|
|
3874
|
+
start: () => setActive(null, null),
|
|
3875
|
+
paint: paintStrip
|
|
3876
|
+
};
|
|
3877
|
+
const uid = useId();
|
|
3878
|
+
let drawings = 0;
|
|
3879
|
+
const SRGB = /^(#|rgba?\()/;
|
|
3880
|
+
let normalizer;
|
|
3881
|
+
/**
|
|
3882
|
+
* A computed color as `#rrggbb` or `rgba(…)`, which every SVG reader
|
|
3883
|
+
* understands. Colors a canvas keeps in another space (`oklab(…)`) are
|
|
3884
|
+
* painted on a pixel and read back.
|
|
3885
|
+
*/
|
|
3886
|
+
function plainColor(color) {
|
|
3887
|
+
if (SRGB.test(color) || typeof document === "undefined") return color;
|
|
3888
|
+
normalizer ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
3889
|
+
if (!normalizer) return color;
|
|
3890
|
+
normalizer.fillStyle = "#000";
|
|
3891
|
+
normalizer.fillStyle = color;
|
|
3892
|
+
const parsed = String(normalizer.fillStyle);
|
|
3893
|
+
if (SRGB.test(parsed)) return parsed;
|
|
3894
|
+
normalizer.clearRect(0, 0, 1, 1);
|
|
3895
|
+
normalizer.fillRect(0, 0, 1, 1);
|
|
3896
|
+
const [r, g, b, alpha] = normalizer.getImageData(0, 0, 1, 1).data;
|
|
3897
|
+
return alpha === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${num(alpha / 255)})`;
|
|
3898
|
+
}
|
|
3899
|
+
/** Room either side of a marker's label on its backdrop. */
|
|
3900
|
+
function svgText(text, x, top, style) {
|
|
3901
|
+
let attributes = ` x="${num(x)}" y="${num(top + 8 + style.size * .35)}"${paintAttribute("fill", style.color)}`;
|
|
3902
|
+
if (style.size !== 12) attributes += ` font-size="${num(style.size)}"`;
|
|
3903
|
+
if (style.weight && style.weight !== "400" && style.weight !== "normal") attributes += ` font-weight="${style.weight}"`;
|
|
3904
|
+
if (style.anchor && style.anchor !== "start") attributes += ` text-anchor="${style.anchor}"`;
|
|
3905
|
+
if (style.halo) attributes += `${paintAttribute("stroke", style.halo)} stroke-width="3" stroke-linejoin="round" paint-order="stroke"`;
|
|
3906
|
+
return `<text${attributes}>${escapeXml(text)}</text>`;
|
|
3907
|
+
}
|
|
3908
|
+
/** `text` cut short with an ellipsis to fit `width`, as a clipped tick label shows. */
|
|
3909
|
+
function fitText(text, width) {
|
|
3910
|
+
if (measure(text) <= width) return text;
|
|
3911
|
+
const chars = [...text];
|
|
3912
|
+
let end = chars.length;
|
|
3913
|
+
while (end > 0 && measure(`${chars.slice(0, end).join("")}…`) > width) end--;
|
|
3914
|
+
return `${chars.slice(0, end).join("")}…`;
|
|
3915
|
+
}
|
|
3916
|
+
/** A legend entry's swatch, as the page draws it. */
|
|
3917
|
+
function swatch(kind, dashed, left, top, color) {
|
|
3918
|
+
const fill = paintAttribute("fill", color);
|
|
3919
|
+
const box = (x, y, width, height, r) => `<rect x="${num(x)}" y="${num(y)}" width="${width}" height="${height}" rx="${r}"${fill}/>`;
|
|
3920
|
+
if (kind === "area" || kind === "radar") return box(left, top + 4, 12, 8, 2);
|
|
3921
|
+
if (kind === "bar" || kind === "pie") return box(left + 2, top + 4, 8, 8, 2);
|
|
3922
|
+
if (kind === "scatter") return `<circle cx="${num(left + 6)}" cy="${num(top + 8)}" r="4"${fill}/>`;
|
|
3923
|
+
if (dashed) return box(left, top + 7, 4, 2, 0) + box(left + 7, top + 7, 4, 2, 0);
|
|
3924
|
+
return box(left, top + 7, 12, 2, 1);
|
|
3925
|
+
}
|
|
3926
|
+
/** A heatmap's scale in the legend, as the page draws it. */
|
|
3927
|
+
function scaleSwatch(svg, stops, left, top, width) {
|
|
3928
|
+
const id = svg.id("scale");
|
|
3929
|
+
return `<defs><linearGradient id="${id}">${stops.map((stop, i) => `<stop offset="${num(i / Math.max(1, stops.length - 1))}"${paintAttribute("stop-color", stop)}/>`).join("")}</linearGradient></defs><rect x="${num(left)}" y="${num(top + 4)}" width="${width}" height="8" rx="2" fill="url(#${id})"/>`;
|
|
3930
|
+
}
|
|
3931
|
+
/** The texts in an element of the chart (a donut's center), where the page shows them. */
|
|
3932
|
+
function textsIn(el, origin, color) {
|
|
3933
|
+
const out = [];
|
|
3934
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
3935
|
+
const range = document.createRange();
|
|
3936
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
3937
|
+
const text = node.textContent?.trim();
|
|
3938
|
+
const parent = node.parentElement;
|
|
3939
|
+
if (!text || !parent) continue;
|
|
3940
|
+
range.selectNodeContents(node);
|
|
3941
|
+
const box = range.getBoundingClientRect();
|
|
3942
|
+
if (!box.width) continue;
|
|
3943
|
+
const style = getComputedStyle(parent);
|
|
3944
|
+
const fontSize = Number.parseFloat(style.fontSize) || 12;
|
|
3945
|
+
out.push(svgText(text, box.left - origin.left, box.top - origin.top + box.height / 2 - 8, {
|
|
3946
|
+
color: color(style.color),
|
|
3947
|
+
size: fontSize,
|
|
3948
|
+
weight: style.fontWeight
|
|
3949
|
+
}));
|
|
3950
|
+
}
|
|
3951
|
+
return out.join("");
|
|
3952
|
+
}
|
|
3953
|
+
/** A filled box; nothing when it has no room. */
|
|
3954
|
+
const svgBox = (r, fill) => r.width > 0 && r.height > 0 ? `<rect x="${num(r.x)}" y="${num(r.y)}" width="${num(r.width)}" height="${num(r.height)}"${paintAttribute("fill", fill)}/>` : "";
|
|
3955
|
+
/** The range strip: its sketch, the parts not shown washed out and the part shown. */
|
|
3956
|
+
function stripMarkup(svg, color, radius) {
|
|
3957
|
+
const state = stripState.value;
|
|
3958
|
+
const sketch = stripSketch.value;
|
|
3959
|
+
if (!state || !sketch) return;
|
|
3960
|
+
const { rect, horizontal } = state;
|
|
3961
|
+
const box = svgBox;
|
|
3962
|
+
svg.raw(`<rect x="${num(rect.x + .5)}" y="${num(rect.y + .5)}" width="${num(rect.width - 1)}" height="${num(rect.height - 1)}" rx="${num(Math.max(0, radius - .5))}" fill="none"${paintAttribute("stroke", color("var(--dou-color-border-subtle)"))}/>`);
|
|
3963
|
+
svg.raw(`<g transform="translate(${num(rect.x)} ${num(rect.y)})">`);
|
|
3964
|
+
svg.layer(.56, () => drawStrip(svg, sketch, 1));
|
|
3965
|
+
svg.raw("</g>");
|
|
3966
|
+
const length = horizontal ? rect.height : rect.width;
|
|
3967
|
+
const start = horizontal ? rect.y : rect.x;
|
|
3968
|
+
const a = start + state.from * length;
|
|
3969
|
+
const b = start + state.to * length;
|
|
3970
|
+
const stretch = (from, to) => horizontal ? {
|
|
3971
|
+
x: rect.x,
|
|
3972
|
+
y: from,
|
|
3973
|
+
width: rect.width,
|
|
3974
|
+
height: to - from
|
|
3975
|
+
} : {
|
|
3976
|
+
x: from,
|
|
3977
|
+
y: rect.y,
|
|
3978
|
+
width: to - from,
|
|
3979
|
+
height: rect.height
|
|
3980
|
+
};
|
|
3981
|
+
const shade = color("color-mix(in oklab, var(--dou-chart-surface) 72%, transparent)");
|
|
3982
|
+
const edge = color("color-mix(in oklab, var(--dou-color-text) 32%, transparent)");
|
|
3983
|
+
const shown = stretch(a, b);
|
|
3984
|
+
svg.raw(box(stretch(start, a), shade) + box(stretch(b, start + length), shade) + box(shown, color("color-mix(in oklab, var(--dou-color-text) 6%, transparent)")));
|
|
3985
|
+
const edges = horizontal ? [{
|
|
3986
|
+
x: shown.x,
|
|
3987
|
+
y: shown.y,
|
|
3988
|
+
width: 1,
|
|
3989
|
+
height: shown.height
|
|
3990
|
+
}, {
|
|
3991
|
+
x: shown.x + shown.width - 1,
|
|
3992
|
+
y: shown.y,
|
|
3993
|
+
width: 1,
|
|
3994
|
+
height: shown.height
|
|
3995
|
+
}] : [{
|
|
3996
|
+
x: shown.x,
|
|
3997
|
+
y: shown.y,
|
|
3998
|
+
width: shown.width,
|
|
3999
|
+
height: 1
|
|
4000
|
+
}, {
|
|
4001
|
+
x: shown.x,
|
|
4002
|
+
y: shown.y + shown.height - 1,
|
|
4003
|
+
width: shown.width,
|
|
4004
|
+
height: 1
|
|
4005
|
+
}];
|
|
4006
|
+
svg.raw(edges.map((r) => box(r, edge)).join(""));
|
|
4007
|
+
}
|
|
4008
|
+
/** The chart as it is shown, as an SVG document. */
|
|
4009
|
+
function exportSVG(options = {}) {
|
|
4010
|
+
const root = rootRef.value;
|
|
4011
|
+
const style = root && typeof getComputedStyle !== "undefined" ? getComputedStyle(root) : null;
|
|
4012
|
+
const svg = new SvgPainter(`${uid}-${drawings++}-`, plainColor);
|
|
4013
|
+
const color = (css) => plainColor(resolveColor(css));
|
|
4014
|
+
drawSvg(svg, color, style);
|
|
4015
|
+
const { width, height, padding } = exportSize(options);
|
|
4016
|
+
const background = options.background === false ? "" : `<rect width="${num(width)}" height="${num(height)}"${paintAttribute("fill", color("var(--dou-chart-surface)"))}/>`;
|
|
4017
|
+
const font = escapeXml(style?.fontFamily || "sans-serif");
|
|
4018
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${num(width)}" height="${num(height)}" viewBox="0 0 ${num(width)} ${num(height)}" font-family="${font}" font-size="12"><title>${escapeXml(label.value)}</title>${background}<g transform="translate(${num(padding)} ${num(padding)})">${svg.markup()}</g></svg>`;
|
|
4019
|
+
}
|
|
4020
|
+
/** The exported image's size: the chart's, with the room around it. */
|
|
4021
|
+
function exportSize(options) {
|
|
4022
|
+
const padding = Math.max(0, options.padding ?? 16);
|
|
4023
|
+
return {
|
|
4024
|
+
width: (frame.value?.width ?? 0) + padding * 2,
|
|
4025
|
+
height: (frame.value?.height ?? 0) + padding * 2,
|
|
4026
|
+
padding
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Draws the whole chart into `svg`: what the canvas shows, then its text.
|
|
4031
|
+
* `color` makes a color the SVG can take; `style` is the chart's computed
|
|
4032
|
+
* style, `null` on the server.
|
|
4033
|
+
*/
|
|
4034
|
+
function drawSvg(svg, color, style) {
|
|
4035
|
+
const l = frame.value;
|
|
4036
|
+
const root = rootRef.value;
|
|
4037
|
+
const width = l?.width ?? 0;
|
|
4038
|
+
const height = l?.height ?? 0;
|
|
4039
|
+
const texts = [];
|
|
4040
|
+
if (l && empty.value) {
|
|
4041
|
+
const text = root?.querySelector(`.${ns.e("empty")}`)?.textContent?.trim() || messages.value.empty;
|
|
4042
|
+
texts.push(svgText(text, width / 2, height / 2 - 8, {
|
|
4043
|
+
color: color("var(--dou-color-text-tertiary)"),
|
|
4044
|
+
size: Number.parseFloat(style?.getPropertyValue("--dou-font-size-sm") ?? "") || 12,
|
|
4045
|
+
anchor: "middle"
|
|
4046
|
+
}));
|
|
4047
|
+
} else if (treeLayout.value) {
|
|
4048
|
+
paintTree(svg, 1, true);
|
|
4049
|
+
const center = root?.querySelector(`.${ns.e("center")}`);
|
|
4050
|
+
if (center && root) texts.push(textsIn(center, root.getBoundingClientRect(), color));
|
|
4051
|
+
} else if (pieLayout.value) {
|
|
4052
|
+
paintPie(svg, 1, 1, true);
|
|
4053
|
+
const center = root?.querySelector(`.${ns.e("center")}`);
|
|
4054
|
+
if (center && root) texts.push(textsIn(center, root.getBoundingClientRect(), color));
|
|
4055
|
+
} else if (funnelLayout.value) {
|
|
4056
|
+
paintFunnel(svg, 1, 1, true);
|
|
4057
|
+
const ink = color("var(--dou-chart-label)");
|
|
4058
|
+
const strong = color("var(--dou-color-text-secondary)");
|
|
4059
|
+
for (const tick of labels.value) {
|
|
4060
|
+
const text = tick.clipped ? fitText(tick.text, tick.width) : tick.text;
|
|
4061
|
+
texts.push(svgText(text, tick.left, tick.top, {
|
|
4062
|
+
color: tick.axis === "value" ? strong : ink,
|
|
4063
|
+
size: 12
|
|
4064
|
+
}));
|
|
4065
|
+
}
|
|
4066
|
+
} else if (radarLayout.value) {
|
|
4067
|
+
paintRadar(svg, 1, 1, true);
|
|
4068
|
+
const ink = color("var(--dou-chart-label)");
|
|
4069
|
+
for (const tick of labels.value) {
|
|
4070
|
+
const text = tick.clipped ? fitText(tick.text, tick.width) : tick.text;
|
|
4071
|
+
texts.push(svgText(text, tick.left, tick.top, {
|
|
4072
|
+
color: ink,
|
|
4073
|
+
size: 12
|
|
4074
|
+
}));
|
|
4075
|
+
}
|
|
4076
|
+
} else if (layout.value) {
|
|
4077
|
+
paint(svg, 1, 1, 1, true);
|
|
4078
|
+
stripMarkup(svg, color, Number.parseFloat(style?.getPropertyValue("--dou-radius-control") ?? "") || 6);
|
|
4079
|
+
const ink = color("var(--dou-chart-label)");
|
|
4080
|
+
for (const tick of labels.value) {
|
|
4081
|
+
const text = tick.clipped ? fitText(tick.text, tick.width) : tick.text;
|
|
4082
|
+
texts.push(svgText(text, tick.left, tick.top, {
|
|
4083
|
+
color: ink,
|
|
4084
|
+
size: 12
|
|
4085
|
+
}));
|
|
4086
|
+
}
|
|
4087
|
+
const title = color("var(--dou-chart-title)");
|
|
4088
|
+
const weight = style?.getPropertyValue("--dou-font-weight-medium").trim() || "500";
|
|
4089
|
+
for (const t of titles.value) texts.push(svgText(t.text, t.left, t.top, {
|
|
4090
|
+
color: title,
|
|
4091
|
+
size: 12,
|
|
4092
|
+
weight,
|
|
4093
|
+
anchor: t.align
|
|
4094
|
+
}));
|
|
4095
|
+
for (const m of markerLabels.value) {
|
|
4096
|
+
if (m.backdrop) {
|
|
4097
|
+
const plate = measure(m.text) + 8;
|
|
4098
|
+
const shift = m.align === "start" ? 0 : m.align === "middle" ? plate / 2 - BACKDROP : plate - 8;
|
|
4099
|
+
texts.push(`<rect x="${num(m.left - BACKDROP - shift)}" y="${num(m.top)}" width="${num(plate)}" height="16" rx="4"${paintAttribute("fill", color(`color-mix(in oklab, ${markerText(m.tone)} 10%, var(--dou-chart-surface))`))}/>`);
|
|
4100
|
+
}
|
|
4101
|
+
texts.push(svgText(m.text, m.left, m.top, {
|
|
4102
|
+
color: color(markerText(m.tone)),
|
|
4103
|
+
size: 12,
|
|
4104
|
+
anchor: m.align
|
|
4105
|
+
}));
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
if (l && !empty.value) {
|
|
4109
|
+
const ink = color("var(--dou-color-text-secondary)");
|
|
4110
|
+
const weight = style?.getPropertyValue("--dou-font-weight-medium").trim() || "500";
|
|
4111
|
+
for (const value of dataLabels.value) {
|
|
4112
|
+
const text = value.width === void 0 ? value.text : fitText(value.text, value.width);
|
|
4113
|
+
texts.push(svgText(text, value.left, value.top, {
|
|
4114
|
+
color: value.ink ? color(value.ink) : ink,
|
|
4115
|
+
size: 12,
|
|
4116
|
+
anchor: value.align,
|
|
4117
|
+
weight: value.on ? weight : void 0
|
|
4118
|
+
}));
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
const on = color("var(--dou-color-text-secondary)");
|
|
4122
|
+
const off = color("var(--dou-color-text-disabled)");
|
|
4123
|
+
const box = l?.legendBox;
|
|
4124
|
+
for (const item of legend.value) {
|
|
4125
|
+
if (box && item.top + 16 > box.y + box.height + .5) continue;
|
|
4126
|
+
const room = (item.swatch ?? 12) + 6;
|
|
4127
|
+
const mark = item.scale ? scaleSwatch(svg, item.scale, item.left, item.top, item.swatch ?? 40) : swatch(item.kind, item.dashed, item.left, item.top, color(item.color));
|
|
4128
|
+
texts.push(item.off ? `<g opacity="0.32">${mark}</g>` : mark);
|
|
4129
|
+
const text = item.clipped ? fitText(item.text, item.width - room) : item.text;
|
|
4130
|
+
texts.push(svgText(text, item.left + room, item.top, {
|
|
4131
|
+
color: item.off ? off : on,
|
|
4132
|
+
size: 12
|
|
4133
|
+
}));
|
|
4134
|
+
}
|
|
4135
|
+
svg.raw(texts.join(""));
|
|
4136
|
+
}
|
|
4137
|
+
const PNG_SOURCE = {
|
|
4138
|
+
scope: "@dou-ui/chart",
|
|
4139
|
+
hint: "use a smaller padding or exportSVG()"
|
|
4140
|
+
};
|
|
4141
|
+
/** The chart as a PNG image, from its SVG. */
|
|
4142
|
+
async function exportPNG(options = {}) {
|
|
4143
|
+
const { width, height } = exportSize(options);
|
|
4144
|
+
return svgToPNG(exportSVG(options), {
|
|
4145
|
+
width,
|
|
4146
|
+
height,
|
|
4147
|
+
pixelRatio: options.pixelRatio,
|
|
4148
|
+
...PNG_SOURCE
|
|
4149
|
+
});
|
|
4150
|
+
}
|
|
4151
|
+
__expose({
|
|
4152
|
+
exportSVG,
|
|
4153
|
+
exportPNG
|
|
4154
|
+
});
|
|
4155
|
+
const serverDrawing = computed(() => {
|
|
4156
|
+
if (typeof window !== "undefined" || !frame.value || empty.value) return "";
|
|
4157
|
+
const svg = new SvgPainter(`${uid}-`);
|
|
4158
|
+
drawSvg(svg, (css) => resolveColor(css), null);
|
|
4159
|
+
return svg.markup();
|
|
4160
|
+
});
|
|
4161
|
+
const staticState = {
|
|
4162
|
+
shown: computed(() => fromServer.value && !!frame.value && !empty.value),
|
|
4163
|
+
size: computed(() => ({
|
|
4164
|
+
width: frame.value?.width ?? 0,
|
|
4165
|
+
height: frame.value?.height ?? 0
|
|
4166
|
+
})),
|
|
4167
|
+
markup: serverDrawing,
|
|
4168
|
+
label
|
|
4169
|
+
};
|
|
4170
|
+
/** Which ways on the screen what is shown can be moved: only when zoomed in along them. */
|
|
4171
|
+
function panDirections() {
|
|
4172
|
+
const { x, y } = zoomAxes.value;
|
|
4173
|
+
const panX = x && !!xWindow.value;
|
|
4174
|
+
const panY = y && !!yWindow.value;
|
|
4175
|
+
return props.horizontal ? {
|
|
4176
|
+
sideways: panY,
|
|
4177
|
+
upDown: panX
|
|
4178
|
+
} : {
|
|
4179
|
+
sideways: panX,
|
|
4180
|
+
upDown: panY
|
|
4181
|
+
};
|
|
4182
|
+
}
|
|
4183
|
+
/** Which way a finger may scroll the page over the plot: not the way it pans. */
|
|
4184
|
+
const touchAction = computed(() => {
|
|
4185
|
+
if (!zoomOn.value) return void 0;
|
|
4186
|
+
const { sideways, upDown } = panDirections();
|
|
4187
|
+
return sideways && upDown ? "none" : sideways ? "pan-y" : upDown ? "pan-x" : "pan-x pan-y";
|
|
4188
|
+
});
|
|
4189
|
+
onBeforeUnmount(() => {
|
|
4190
|
+
if (drag) endDrag(false);
|
|
4191
|
+
if (gestureFrame) cancelAnimationFrame(gestureFrame);
|
|
4192
|
+
});
|
|
4193
|
+
const cssLength = (value) => typeof value === "number" ? `${value}px` : value;
|
|
4194
|
+
return (_ctx, _cache) => {
|
|
4195
|
+
return openBlock(), createElementBlock("div", {
|
|
4196
|
+
ref: "root",
|
|
4197
|
+
class: normalizeClass([
|
|
4198
|
+
unref(ns).b(),
|
|
4199
|
+
unref(ns).is("interactive", interactive.value),
|
|
4200
|
+
unref(ns).is("zoomed", zoomOn.value && zoomed.value),
|
|
4201
|
+
unref(ns).is("brushing", !!brushPart.value && !zoomOn.value),
|
|
4202
|
+
unref(ns).is("loading", !!__props.loading),
|
|
4203
|
+
unref(ns).is("table", table.shown.value)
|
|
4204
|
+
]),
|
|
4205
|
+
style: normalizeStyle({
|
|
4206
|
+
height: cssLength(__props.height),
|
|
4207
|
+
touchAction: touchAction.value
|
|
4208
|
+
}),
|
|
4209
|
+
role: fromServer.value ? void 0 : interactive.value || table.shown.value ? "group" : "img",
|
|
4210
|
+
"aria-roledescription": interactive.value && !fromServer.value ? unref(messages).chart : void 0,
|
|
4211
|
+
"aria-label": fromServer.value ? void 0 : label.value,
|
|
4212
|
+
"aria-busy": __props.loading || void 0,
|
|
4213
|
+
tabindex: interactive.value && !fromServer.value ? 0 : void 0,
|
|
4214
|
+
onPointermove: onPointerMove,
|
|
4215
|
+
onPointerleave: onPointerLeave,
|
|
4216
|
+
onPointerdown: onPointerDown,
|
|
4217
|
+
onPointerup: onPointerUp,
|
|
4218
|
+
onPointercancel: onPointerCancel,
|
|
4219
|
+
onKeydown,
|
|
4220
|
+
onClick,
|
|
4221
|
+
onWheel,
|
|
4222
|
+
onDblclick,
|
|
4223
|
+
onBlur: _cache[0] || (_cache[0] = ($event) => active.value && !active.value.pointer && setActive(null, null))
|
|
4224
|
+
}, [
|
|
4225
|
+
createElementVNode("div", {
|
|
4226
|
+
class: normalizeClass(unref(ns).e("parts")),
|
|
4227
|
+
hidden: ""
|
|
4228
|
+
}, [renderSlot(_ctx.$slots, "default")], 2),
|
|
4229
|
+
createElementVNode("canvas", {
|
|
4230
|
+
ref: "canvas",
|
|
4231
|
+
class: normalizeClass(unref(ns).e("canvas")),
|
|
4232
|
+
"aria-hidden": "true"
|
|
4233
|
+
}, null, 2),
|
|
4234
|
+
createVNode(static_default, { state: staticState }),
|
|
4235
|
+
createVNode(overlay_default, { state: overlay }),
|
|
4236
|
+
createVNode(zoom_layer_default, { state: zoomLayer }),
|
|
4237
|
+
createVNode(pointer_default, { state: pointer }),
|
|
4238
|
+
createVNode(table_default, { state: table })
|
|
4239
|
+
], 46, _hoisted_1);
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
});
|
|
4243
|
+
export { chart_vue_vue_type_script_setup_true_lang_default as default };
|
|
4244
|
+
|
|
4245
|
+
//# sourceMappingURL=chart.vue_vue_type_script_setup_true_lang.js.map
|