@artooi/ag-ui-web-component 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -1
- package/README.md +47 -3
- package/dist/ag-ui-web-component.bundle.js +119 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/index.js +449 -100
- package/dist/index.js.map +4 -4
- package/dist/ui/chart_block.d.ts +18 -0
- package/dist/ui/chart_block.d.ts.map +1 -1
- package/dist/ui/clamp_panel.d.ts +13 -0
- package/dist/ui/clamp_panel.d.ts.map +1 -0
- package/dist/ui/launcher_placement.d.ts.map +1 -1
- package/dist/ui/panel_drag.d.ts +40 -0
- package/dist/ui/panel_drag.d.ts.map +1 -0
- package/dist/ui/place_widget.d.ts +23 -0
- package/dist/ui/place_widget.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +8 -0
- package/src/core/ag_ui_chat.ts +207 -9
- package/src/ui/chart_block.ts +222 -57
- package/src/ui/clamp_panel.ts +24 -0
- package/src/ui/launcher_placement.ts +18 -54
- package/src/ui/panel_drag.ts +134 -0
- package/src/ui/place_widget.ts +56 -0
- package/src/ui/styles.ts +75 -3
- package/src/version.ts +1 -1
package/src/ui/chart_block.ts
CHANGED
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
* Hand-rolled rather than a charting library, and the difference is not
|
|
12
12
|
* marginal: the whole renderer costs single-digit kilobytes where a library
|
|
13
13
|
* costs roughly half this bundle again, in a component distributed over a CDN.
|
|
14
|
+
*
|
|
15
|
+
* **The drawing is sized in CSS pixels, not scaled to them.** An SVG with a
|
|
16
|
+
* fixed viewBox and `width: 100%` is a picture the browser magnifies: widen the
|
|
17
|
+
* panel and every stroke, every label and the whole frame grow with it. So the
|
|
18
|
+
* geometry is computed for the width the block actually has, one user unit to
|
|
19
|
+
* one CSS pixel, and recomputed when that width changes -- a 10px label is
|
|
20
|
+
* 10px at every size the block is given.
|
|
21
|
+
*
|
|
22
|
+
* How wide the block is allowed to get is the stylesheet's question, not this
|
|
23
|
+
* module's, and the answer there is that a chart stops at its own width rather
|
|
24
|
+
* than stretching with the panel, the way a message does. The two halves are
|
|
25
|
+
* separate on purpose: this one keeps a chart from being magnified, that one
|
|
26
|
+
* keeps it from being stretched, and a host that raises the cap gets a bigger
|
|
27
|
+
* chart with the same 10px labels rather than a magnified one.
|
|
14
28
|
*/
|
|
15
29
|
|
|
16
30
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
@@ -32,11 +46,55 @@ export interface ChartSpec {
|
|
|
32
46
|
readonly series: readonly ChartSeries[];
|
|
33
47
|
}
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
/** The frame a chart is drawn into, in CSS pixels. */
|
|
50
|
+
interface Geometry {
|
|
51
|
+
readonly width: number;
|
|
52
|
+
readonly height: number;
|
|
53
|
+
/** The plotting area inside the padding. */
|
|
54
|
+
readonly plotW: number;
|
|
55
|
+
readonly plotH: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
const PAD = { top: 20, right: 12, bottom: 30, left: 44 };
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The width drawn before the block has been measured -- while it is still
|
|
62
|
+
* detached, and in a caller that never puts it in a document at all. It is the
|
|
63
|
+
* width this renderer drew at unconditionally before it could measure, so a
|
|
64
|
+
* chart that is never measured looks exactly as it always did.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_WIDTH = 480;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The narrowest frame worth computing. Below this the SVG scales down as it
|
|
70
|
+
* always did, which is the right answer at the bottom end: a 200px chart with
|
|
71
|
+
* 10px labels has no room for the labels either way, and shrinking them keeps
|
|
72
|
+
* the shape readable.
|
|
73
|
+
*/
|
|
74
|
+
const MIN_WIDTH = 220;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How tall a chart is for its width, and the band that holds. The ratio is the
|
|
78
|
+
* old fixed 480x220 frame, so the default width draws precisely what it drew
|
|
79
|
+
* before; the band is what stops a wide panel from turning a chart into a
|
|
80
|
+
* banner or a narrow one into a strip.
|
|
81
|
+
*/
|
|
82
|
+
const HEIGHT_RATIO = 220 / 480;
|
|
83
|
+
const MIN_HEIGHT = 160;
|
|
84
|
+
const MAX_HEIGHT = 320;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The step a measured width is rounded to before it is redrawn.
|
|
88
|
+
*
|
|
89
|
+
* The SVG keeps `width="100%"`, so it fills its block exactly whatever the
|
|
90
|
+
* viewBox says; rounding the viewBox to 8px therefore costs at most a 3%
|
|
91
|
+
* scale at the narrow end and nothing anyone can see, while cutting the
|
|
92
|
+
* redraws during a panel drag from one per pixel to one per eight.
|
|
93
|
+
*/
|
|
94
|
+
const WIDTH_STEP = 8;
|
|
95
|
+
|
|
96
|
+
/** A rough advance width per character for the 10px axis font. */
|
|
97
|
+
const AXIS_CHAR_WIDTH = 5.6;
|
|
40
98
|
|
|
41
99
|
// Read from the host's own palette rather than a fixed ramp: the component
|
|
42
100
|
// themes through custom properties everywhere else, and a chart that ignored
|
|
@@ -57,6 +115,18 @@ export function seriesColor(index: number): string {
|
|
|
57
115
|
return SERIES_COLORS[index % SERIES_COLORS.length] as string;
|
|
58
116
|
}
|
|
59
117
|
|
|
118
|
+
/** The frame for a block of this width. */
|
|
119
|
+
function geometryFor(width: number): Geometry {
|
|
120
|
+
const w = Math.max(MIN_WIDTH, width);
|
|
121
|
+
const h = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(w * HEIGHT_RATIO)));
|
|
122
|
+
return {
|
|
123
|
+
width: w,
|
|
124
|
+
height: h,
|
|
125
|
+
plotW: w - PAD.left - PAD.right,
|
|
126
|
+
plotH: h - PAD.top - PAD.bottom,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
60
130
|
function el<K extends keyof SVGElementTagNameMap>(
|
|
61
131
|
name: K,
|
|
62
132
|
attrs: Record<string, string | number>,
|
|
@@ -108,23 +178,44 @@ function extent(spec: ChartSpec): { min: number; max: number } {
|
|
|
108
178
|
return max === min ? { min, max: max + 1 } : { min, max };
|
|
109
179
|
}
|
|
110
180
|
|
|
111
|
-
function scaleY(value: number, min: number, max: number): number {
|
|
112
|
-
return PAD.top +
|
|
181
|
+
function scaleY(value: number, min: number, max: number, geo: Geometry): number {
|
|
182
|
+
return PAD.top + geo.plotH - ((value - min) / (max - min)) * geo.plotH;
|
|
113
183
|
}
|
|
114
184
|
|
|
115
|
-
function bandCentre(index: number, count: number): number {
|
|
116
|
-
const step =
|
|
185
|
+
function bandCentre(index: number, count: number, geo: Geometry): number {
|
|
186
|
+
const step = geo.plotW / count;
|
|
117
187
|
return PAD.left + step * index + step / 2;
|
|
118
188
|
}
|
|
119
189
|
|
|
120
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Draw every nth label, for the smallest n whose labels have room.
|
|
192
|
+
*
|
|
193
|
+
* Axis text is a fixed 10px now rather than something that shrank with the
|
|
194
|
+
* frame, so at the narrow end the labels are the first thing to collide -- and
|
|
195
|
+
* a smear of overlapping words says less than half as many words with space
|
|
196
|
+
* around them. The character estimate only has to be good enough to pick a
|
|
197
|
+
* step; measuring text properly would mean laying it out first.
|
|
198
|
+
*/
|
|
199
|
+
function labelStride(labels: readonly string[], geo: Geometry): number {
|
|
200
|
+
const band = geo.plotW / labels.length;
|
|
201
|
+
const widest = Math.max(...labels.map((label) => label.length)) * AXIS_CHAR_WIDTH;
|
|
202
|
+
return Math.max(1, Math.ceil(widest / band));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function drawAxes(
|
|
206
|
+
svg: SVGSVGElement,
|
|
207
|
+
spec: ChartSpec,
|
|
208
|
+
geo: Geometry,
|
|
209
|
+
min: number,
|
|
210
|
+
max: number,
|
|
211
|
+
): void {
|
|
121
212
|
for (const value of [min, max]) {
|
|
122
|
-
const y = scaleY(value, min, max);
|
|
213
|
+
const y = scaleY(value, min, max, geo);
|
|
123
214
|
svg.appendChild(
|
|
124
215
|
el("line", {
|
|
125
216
|
x1: PAD.left,
|
|
126
217
|
y1: y,
|
|
127
|
-
x2:
|
|
218
|
+
x2: geo.width - PAD.right,
|
|
128
219
|
y2: y,
|
|
129
220
|
stroke: "currentColor",
|
|
130
221
|
"stroke-opacity": value === min ? 0.35 : 0.12,
|
|
@@ -134,24 +225,34 @@ function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
|
|
|
134
225
|
text(String(Math.round(value)), { x: PAD.left - 6, y: y + 4, "text-anchor": "end" }),
|
|
135
226
|
);
|
|
136
227
|
}
|
|
228
|
+
const stride = labelStride(spec.labels, geo);
|
|
137
229
|
spec.labels.forEach((label, i) => {
|
|
230
|
+
if (i % stride !== 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
138
233
|
svg.appendChild(
|
|
139
234
|
text(label, {
|
|
140
|
-
x: bandCentre(i, spec.labels.length),
|
|
141
|
-
y:
|
|
235
|
+
x: bandCentre(i, spec.labels.length, geo),
|
|
236
|
+
y: geo.height - PAD.bottom + 16,
|
|
142
237
|
"text-anchor": "middle",
|
|
143
238
|
}),
|
|
144
239
|
);
|
|
145
240
|
});
|
|
146
241
|
}
|
|
147
242
|
|
|
148
|
-
function drawBars(
|
|
149
|
-
|
|
243
|
+
function drawBars(
|
|
244
|
+
svg: SVGSVGElement,
|
|
245
|
+
spec: ChartSpec,
|
|
246
|
+
geo: Geometry,
|
|
247
|
+
min: number,
|
|
248
|
+
max: number,
|
|
249
|
+
): void {
|
|
250
|
+
const step = geo.plotW / spec.labels.length;
|
|
150
251
|
const width = (step * 0.7) / spec.series.length;
|
|
151
|
-
const base = scaleY(min, min, max);
|
|
252
|
+
const base = scaleY(min, min, max, geo);
|
|
152
253
|
spec.series.forEach((series, s) => {
|
|
153
254
|
series.points.forEach((value, i) => {
|
|
154
|
-
const y = scaleY(value, min, max);
|
|
255
|
+
const y = scaleY(value, min, max, geo);
|
|
155
256
|
svg.appendChild(
|
|
156
257
|
el("rect", {
|
|
157
258
|
x: PAD.left + step * i + step * 0.15 + width * s,
|
|
@@ -166,8 +267,14 @@ function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number)
|
|
|
166
267
|
});
|
|
167
268
|
}
|
|
168
269
|
|
|
169
|
-
function drawStacked(
|
|
170
|
-
|
|
270
|
+
function drawStacked(
|
|
271
|
+
svg: SVGSVGElement,
|
|
272
|
+
spec: ChartSpec,
|
|
273
|
+
geo: Geometry,
|
|
274
|
+
min: number,
|
|
275
|
+
max: number,
|
|
276
|
+
): void {
|
|
277
|
+
const step = geo.plotW / spec.labels.length;
|
|
171
278
|
const width = step * 0.7;
|
|
172
279
|
// Indexed by the same `i` it was built from, so every read is a hit; cast
|
|
173
280
|
// rather than defaulting, which would add a branch nothing can reach.
|
|
@@ -182,13 +289,13 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
|
|
|
182
289
|
const from = running[i] ?? 0;
|
|
183
290
|
const to = from + value;
|
|
184
291
|
running[i] = to;
|
|
185
|
-
const y = scaleY(to, min, max);
|
|
292
|
+
const y = scaleY(to, min, max, geo);
|
|
186
293
|
svg.appendChild(
|
|
187
294
|
el("rect", {
|
|
188
295
|
x: PAD.left + step * i + step * 0.15,
|
|
189
296
|
y,
|
|
190
297
|
width,
|
|
191
|
-
height: Math.max(1, scaleY(from, min, max) - y),
|
|
298
|
+
height: Math.max(1, scaleY(from, min, max, geo) - y),
|
|
192
299
|
fill: seriesColor(s),
|
|
193
300
|
}),
|
|
194
301
|
);
|
|
@@ -196,10 +303,18 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
|
|
|
196
303
|
});
|
|
197
304
|
}
|
|
198
305
|
|
|
199
|
-
function drawLines(
|
|
306
|
+
function drawLines(
|
|
307
|
+
svg: SVGSVGElement,
|
|
308
|
+
spec: ChartSpec,
|
|
309
|
+
geo: Geometry,
|
|
310
|
+
min: number,
|
|
311
|
+
max: number,
|
|
312
|
+
): void {
|
|
200
313
|
spec.series.forEach((series, s) => {
|
|
201
314
|
const points = series.points
|
|
202
|
-
.map(
|
|
315
|
+
.map(
|
|
316
|
+
(value, i) => `${bandCentre(i, spec.labels.length, geo)},${scaleY(value, min, max, geo)}`,
|
|
317
|
+
)
|
|
203
318
|
.join(" ");
|
|
204
319
|
svg.appendChild(
|
|
205
320
|
el("polyline", {
|
|
@@ -213,13 +328,19 @@ function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number
|
|
|
213
328
|
});
|
|
214
329
|
}
|
|
215
330
|
|
|
216
|
-
function drawScatter(
|
|
331
|
+
function drawScatter(
|
|
332
|
+
svg: SVGSVGElement,
|
|
333
|
+
spec: ChartSpec,
|
|
334
|
+
geo: Geometry,
|
|
335
|
+
min: number,
|
|
336
|
+
max: number,
|
|
337
|
+
): void {
|
|
217
338
|
spec.series.forEach((series, s) => {
|
|
218
339
|
series.points.forEach((value, i) => {
|
|
219
340
|
svg.appendChild(
|
|
220
341
|
el("circle", {
|
|
221
|
-
cx: bandCentre(i, spec.labels.length),
|
|
222
|
-
cy: scaleY(value, min, max),
|
|
342
|
+
cx: bandCentre(i, spec.labels.length, geo),
|
|
343
|
+
cy: scaleY(value, min, max, geo),
|
|
223
344
|
r: 4,
|
|
224
345
|
fill: seriesColor(s),
|
|
225
346
|
"fill-opacity": 0.85,
|
|
@@ -231,15 +352,15 @@ function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
|
|
|
231
352
|
|
|
232
353
|
/**
|
|
233
354
|
* Pie draws the **first** series' points as shares of their own total, one
|
|
234
|
-
* wedge per label
|
|
355
|
+
* wedge per label -- the only kind whose slices are the labels rather than the
|
|
235
356
|
* series, so a second series has nowhere to go and is ignored rather than
|
|
236
357
|
* silently summed into the first.
|
|
237
358
|
*/
|
|
238
|
-
function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
|
|
359
|
+
function drawPie(svg: SVGSVGElement, points: readonly number[], geo: Geometry): void {
|
|
239
360
|
const total = points.reduce((sum, value) => sum + value, 0);
|
|
240
|
-
const cx =
|
|
241
|
-
const cy = PAD.top +
|
|
242
|
-
const r = Math.min(
|
|
361
|
+
const cx = geo.width / 2;
|
|
362
|
+
const cy = PAD.top + geo.plotH / 2;
|
|
363
|
+
const r = Math.min(geo.plotW, geo.plotH) / 2;
|
|
243
364
|
if (total === 0) {
|
|
244
365
|
// Every share is zero, so there is no wedge to draw and a full circle would
|
|
245
366
|
// claim one slice owns everything. An outline says "nothing here" honestly.
|
|
@@ -273,6 +394,42 @@ function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
|
|
|
273
394
|
});
|
|
274
395
|
}
|
|
275
396
|
|
|
397
|
+
/** The whole drawing for one spec at one width. */
|
|
398
|
+
function drawSvg(spec: ChartSpec, geo: Geometry): SVGSVGElement {
|
|
399
|
+
const svg = el("svg", {
|
|
400
|
+
viewBox: `0 0 ${geo.width} ${geo.height}`,
|
|
401
|
+
width: "100%",
|
|
402
|
+
role: "img",
|
|
403
|
+
});
|
|
404
|
+
svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
|
|
405
|
+
|
|
406
|
+
if (spec.kind === "pie") {
|
|
407
|
+
// `series[0]` is guaranteed by renderChart's early return; a pie's slices
|
|
408
|
+
// are its labels, so a second series has nowhere to go and is ignored
|
|
409
|
+
// rather than silently summed into the first. Negative shares are floored,
|
|
410
|
+
// since a wedge cannot sweep backwards.
|
|
411
|
+
const first = spec.series[0] as ChartSeries;
|
|
412
|
+
drawPie(
|
|
413
|
+
svg,
|
|
414
|
+
first.points.map((value) => Math.max(0, value)),
|
|
415
|
+
geo,
|
|
416
|
+
);
|
|
417
|
+
return svg;
|
|
418
|
+
}
|
|
419
|
+
const { min, max } = extent(spec);
|
|
420
|
+
drawAxes(svg, spec, geo, min, max);
|
|
421
|
+
if (spec.kind === "bar") {
|
|
422
|
+
drawBars(svg, spec, geo, min, max);
|
|
423
|
+
} else if (spec.kind === "stacked") {
|
|
424
|
+
drawStacked(svg, spec, geo, min, max);
|
|
425
|
+
} else if (spec.kind === "line") {
|
|
426
|
+
drawLines(svg, spec, geo, min, max);
|
|
427
|
+
} else {
|
|
428
|
+
drawScatter(svg, spec, geo, min, max);
|
|
429
|
+
}
|
|
430
|
+
return svg;
|
|
431
|
+
}
|
|
432
|
+
|
|
276
433
|
function buildLegend(entries: readonly string[]): HTMLDivElement | null {
|
|
277
434
|
if (entries.length < 2) {
|
|
278
435
|
return null;
|
|
@@ -292,12 +449,34 @@ function buildLegend(entries: readonly string[]): HTMLDivElement | null {
|
|
|
292
449
|
return row;
|
|
293
450
|
}
|
|
294
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Redraw `block`'s chart whenever the width it has to fill changes.
|
|
454
|
+
*
|
|
455
|
+
* Nothing disconnects this, and nothing needs to: the observer is referenced
|
|
456
|
+
* only by the closure that made it, and an active observer holds its target
|
|
457
|
+
* rather than the other way round -- so a chart removed from the transcript
|
|
458
|
+
* takes its observer with it.
|
|
459
|
+
*
|
|
460
|
+
* The block's own width is read rather than the entry's box, because that is
|
|
461
|
+
* the number the redraw has to match and the entry carries several.
|
|
462
|
+
*/
|
|
463
|
+
function fitToWidth(block: HTMLElement, redraw: (width: number) => void): void {
|
|
464
|
+
const observer = new ResizeObserver(() => {
|
|
465
|
+
redraw(Math.round(block.clientWidth / WIDTH_STEP) * WIDTH_STEP);
|
|
466
|
+
});
|
|
467
|
+
observer.observe(block);
|
|
468
|
+
}
|
|
469
|
+
|
|
295
470
|
/**
|
|
296
471
|
* Render one spec as a self-contained block, or `null` when it says nothing.
|
|
297
472
|
*
|
|
298
473
|
* A spec with no labels or no series is not drawn: an empty frame reads as
|
|
299
474
|
* "there is no data" when the truth is "the caller sent nothing", and the two
|
|
300
475
|
* deserve different answers.
|
|
476
|
+
*
|
|
477
|
+
* The block returned is already drawn at {@link DEFAULT_WIDTH} and redraws
|
|
478
|
+
* itself once it is in a document and knows how wide it really is, so a caller
|
|
479
|
+
* appends it exactly as before and never has to say how big it should be.
|
|
301
480
|
*/
|
|
302
481
|
export function renderChart(spec: ChartSpec): HTMLDivElement | null {
|
|
303
482
|
if (spec.labels.length === 0 || spec.series.length === 0) {
|
|
@@ -315,32 +494,8 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
|
|
|
315
494
|
block.appendChild(heading);
|
|
316
495
|
}
|
|
317
496
|
|
|
318
|
-
|
|
319
|
-
svg
|
|
320
|
-
|
|
321
|
-
if (spec.kind === "pie") {
|
|
322
|
-
// `series[0]` is guaranteed by the early return above; a pie's slices are
|
|
323
|
-
// its labels, so a second series has nowhere to go and is ignored rather
|
|
324
|
-
// than silently summed into the first. Negative shares are floored, since a
|
|
325
|
-
// wedge cannot sweep backwards.
|
|
326
|
-
const first = spec.series[0] as ChartSeries;
|
|
327
|
-
drawPie(
|
|
328
|
-
svg,
|
|
329
|
-
first.points.map((value) => Math.max(0, value)),
|
|
330
|
-
);
|
|
331
|
-
} else {
|
|
332
|
-
const { min, max } = extent(spec);
|
|
333
|
-
drawAxes(svg, spec, min, max);
|
|
334
|
-
if (spec.kind === "bar") {
|
|
335
|
-
drawBars(svg, spec, min, max);
|
|
336
|
-
} else if (spec.kind === "stacked") {
|
|
337
|
-
drawStacked(svg, spec, min, max);
|
|
338
|
-
} else if (spec.kind === "line") {
|
|
339
|
-
drawLines(svg, spec, min, max);
|
|
340
|
-
} else {
|
|
341
|
-
drawScatter(svg, spec, min, max);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
497
|
+
let width = DEFAULT_WIDTH;
|
|
498
|
+
let svg = drawSvg(spec, geometryFor(width));
|
|
344
499
|
block.appendChild(svg);
|
|
345
500
|
|
|
346
501
|
// Pie's slices are its labels; every other kind's are its series.
|
|
@@ -350,5 +505,15 @@ export function renderChart(spec: ChartSpec): HTMLDivElement | null {
|
|
|
350
505
|
if (legend !== null) {
|
|
351
506
|
block.appendChild(legend);
|
|
352
507
|
}
|
|
508
|
+
|
|
509
|
+
fitToWidth(block, (measured) => {
|
|
510
|
+
if (measured === width) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
width = measured;
|
|
514
|
+
const next = drawSvg(spec, geometryFor(width));
|
|
515
|
+
svg.replaceWith(next);
|
|
516
|
+
svg = next;
|
|
517
|
+
});
|
|
353
518
|
return block;
|
|
354
519
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { EDGE_MARGIN } from "../constants.js";
|
|
2
|
+
import type { Extent } from "./launcher_placement.js";
|
|
3
|
+
import type { PanelRect } from "./resize_handle.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hold a panel inside the viewport, keeping its size.
|
|
7
|
+
*
|
|
8
|
+
* The near edge is clamped and the far edge follows, so a panel too large for
|
|
9
|
+
* the viewport is held against the near margin and left to the max-width and
|
|
10
|
+
* max-height rules rather than centred by force. The lower bound wins a
|
|
11
|
+
* contradiction for the same reason: `Math.min` first would put an oversized
|
|
12
|
+
* panel off the left edge instead of against the margin it can still honour.
|
|
13
|
+
*/
|
|
14
|
+
export function clampPanel(
|
|
15
|
+
host: PanelRect,
|
|
16
|
+
viewport: Extent,
|
|
17
|
+
margin: number = EDGE_MARGIN,
|
|
18
|
+
): PanelRect {
|
|
19
|
+
const width = host.right - host.left;
|
|
20
|
+
const height = host.bottom - host.top;
|
|
21
|
+
const left = Math.max(margin, Math.min(host.left, viewport.width - margin - width));
|
|
22
|
+
const top = Math.max(margin, Math.min(host.top, viewport.height - margin - height));
|
|
23
|
+
return { left, top, right: left + width, bottom: top + height };
|
|
24
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { EDGE_MARGIN } from "../constants.js";
|
|
2
|
+
import { clampPanel } from "./clamp_panel.js";
|
|
3
|
+
import { placeWidget } from "./place_widget.js";
|
|
4
|
+
|
|
1
5
|
/** A box in viewport coordinates. */
|
|
2
6
|
export interface LauncherBox {
|
|
3
7
|
readonly left: number;
|
|
@@ -33,13 +37,6 @@ export interface LauncherPlacement {
|
|
|
33
37
|
readonly launcherInset: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/**
|
|
37
|
-
* The gutter a panel keeps from the viewport edge, matching the default
|
|
38
|
-
* `--ag-ui-inset` so an undragged widget resolves to exactly the placement it
|
|
39
|
-
* already had. Changing this moves every clamped panel.
|
|
40
|
-
*/
|
|
41
|
-
const EDGE_MARGIN = 24;
|
|
42
|
-
|
|
43
40
|
/**
|
|
44
41
|
* Decide where a panel should open from a launcher the user has dragged.
|
|
45
42
|
*
|
|
@@ -88,56 +85,23 @@ export function launcherPlacement(
|
|
|
88
85
|
y: roomRunningDown >= roomRunningUp ? "top" : "bottom",
|
|
89
86
|
};
|
|
90
87
|
|
|
91
|
-
// The box the panel wants, then the box it can actually have.
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// to the max-width and max-height rules rather than centred by force.
|
|
88
|
+
// The box the panel wants, then the box it can actually have. The launcher
|
|
89
|
+
// is not moved by that clamp -- it is the fixed point of this gesture, so its
|
|
90
|
+
// own inset carries the difference and it can end up outside the host box.
|
|
95
91
|
const wantedLeft =
|
|
96
92
|
corner.x === "left" ? launcher.left : launcher.left + launcher.width - panel.width;
|
|
97
93
|
const wantedTop =
|
|
98
94
|
corner.y === "top" ? launcher.top : launcher.top + launcher.height - panel.height;
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
right: corner.x === "right" ? viewport.width - hostRight : null,
|
|
110
|
-
bottom: corner.y === "bottom" ? viewport.height - hostBottom : null,
|
|
111
|
-
left: corner.x === "left" ? hostLeft : null,
|
|
112
|
-
}),
|
|
113
|
-
// Measured from the host box's pinned corner to the launcher's matching
|
|
114
|
-
// corner, so the launcher lands exactly where it was dropped.
|
|
115
|
-
launcherInset: inset({
|
|
116
|
-
top: corner.y === "top" ? launcher.top - hostTop : null,
|
|
117
|
-
right: corner.x === "right" ? hostRight - (launcher.left + launcher.width) : null,
|
|
118
|
-
bottom: corner.y === "bottom" ? hostBottom - (launcher.top + launcher.height) : null,
|
|
119
|
-
left: corner.x === "left" ? launcher.left - hostLeft : null,
|
|
120
|
-
}),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Constrain a value, with the lower bound winning a contradiction. `Math.min`
|
|
126
|
-
* first would put a panel wider than the viewport off the left edge instead of
|
|
127
|
-
* against the near margin.
|
|
128
|
-
*/
|
|
129
|
-
function clamp(value: number, low: number, high: number): number {
|
|
130
|
-
return Math.max(low, Math.min(value, high));
|
|
131
|
-
}
|
|
95
|
+
const host = clampPanel(
|
|
96
|
+
{
|
|
97
|
+
left: wantedLeft,
|
|
98
|
+
top: wantedTop,
|
|
99
|
+
right: wantedLeft + panel.width,
|
|
100
|
+
bottom: wantedTop + panel.height,
|
|
101
|
+
},
|
|
102
|
+
viewport,
|
|
103
|
+
margin,
|
|
104
|
+
);
|
|
132
105
|
|
|
133
|
-
|
|
134
|
-
function inset(sides: {
|
|
135
|
-
top: number | null;
|
|
136
|
-
right: number | null;
|
|
137
|
-
bottom: number | null;
|
|
138
|
-
left: number | null;
|
|
139
|
-
}): string {
|
|
140
|
-
const side = (value: number | null): string =>
|
|
141
|
-
value === null ? "auto" : `${Math.round(value)}px`;
|
|
142
|
-
return `${side(sides.top)} ${side(sides.right)} ${side(sides.bottom)} ${side(sides.left)}`;
|
|
106
|
+
return { corner, ...placeWidget(host, launcher, corner, viewport) };
|
|
143
107
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { PanelRect } from "./resize_handle.js";
|
|
2
|
+
|
|
3
|
+
/** What the drag needs from its host to do its job. */
|
|
4
|
+
export interface PanelDragOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the panel can be moved right now, read per interaction because
|
|
7
|
+
* both halves of the answer are live: a docked or full-bleed placement has
|
|
8
|
+
* nowhere to move the panel to, and a collapsed one has no panel on screen.
|
|
9
|
+
*/
|
|
10
|
+
readonly enabled: () => boolean;
|
|
11
|
+
/** The panel's current box, in viewport coordinates. */
|
|
12
|
+
readonly rect: () => PanelRect;
|
|
13
|
+
/**
|
|
14
|
+
* Put the panel at this box. Called per pointer move, with the box the press
|
|
15
|
+
* started on -- the whole gesture is one translation of that box, and a host
|
|
16
|
+
* with anything else to move alongside the panel needs the same distance
|
|
17
|
+
* rather than a distance measured from wherever the last move left things.
|
|
18
|
+
*/
|
|
19
|
+
readonly apply: (box: PanelRect, from: PanelRect) => void;
|
|
20
|
+
/** Called once per completed move, for persistence. Never per pointer move. */
|
|
21
|
+
readonly commit: (box: PanelRect, from: PanelRect) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How far the pointer must travel before this counts as a drag. The header is
|
|
26
|
+
* also where the title is selected and the controls are pressed, so a press
|
|
27
|
+
* that wanders by a pixel has to remain a press.
|
|
28
|
+
*/
|
|
29
|
+
const DRAG_THRESHOLD = 4;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Elements inside the header that own their own press. A drag started on one
|
|
33
|
+
* of these would move the panel out from under the control the user was
|
|
34
|
+
* aiming at, and every one of them is the only way to reach what it does.
|
|
35
|
+
*/
|
|
36
|
+
const CONTROLS = "button, a[href], input, select, textarea, [contenteditable]";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Let the user move the whole widget by dragging the panel's header.
|
|
40
|
+
*
|
|
41
|
+
* The launcher can already be dragged, and this is the same gesture on the
|
|
42
|
+
* half of the widget that is on screen when it is open -- a chat panel is a
|
|
43
|
+
* window, and a window moves by its title bar. The two stay one position
|
|
44
|
+
* rather than two: the host answers a moved panel by moving the launcher with
|
|
45
|
+
* it, so collapsing after a drag leaves the launcher where the panel was.
|
|
46
|
+
*
|
|
47
|
+
* **No keyboard path here, deliberately.** Every other drag in this component
|
|
48
|
+
* has arrow keys on the handle, because the handle is a control and the
|
|
49
|
+
* capability is reachable nowhere else. A header is not a control: making it
|
|
50
|
+
* focusable would put a tab stop with no role in front of every keyboard user,
|
|
51
|
+
* ahead of the controls they actually came for. The capability is not lost --
|
|
52
|
+
* arrow keys on the collapsed launcher move the widget, and the panel follows
|
|
53
|
+
* it -- so what is missing is a shortcut, not the ability.
|
|
54
|
+
*/
|
|
55
|
+
export function enablePanelDrag(handle: HTMLElement, options: PanelDragOptions): void {
|
|
56
|
+
handle.addEventListener("pointerdown", (event: PointerEvent) => {
|
|
57
|
+
// Secondary buttons open menus and paste on the platforms that have them;
|
|
58
|
+
// none of that is a drag.
|
|
59
|
+
//
|
|
60
|
+
// Three conditions in one arc, which coverage counts as one branch however
|
|
61
|
+
// many of them are deleted. Each is held by a named test in
|
|
62
|
+
// panel_drag.test.ts -- "ignores a secondary button", "does nothing where
|
|
63
|
+
// the placement has nowhere to move the panel", and "steps aside for a
|
|
64
|
+
// control in the header" -- verified by mutating each one out.
|
|
65
|
+
if (event.button !== 0 || !options.enabled() || onControl(event, handle)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const start = options.rect();
|
|
69
|
+
const originX = event.clientX;
|
|
70
|
+
const originY = event.clientY;
|
|
71
|
+
let dragging = false;
|
|
72
|
+
|
|
73
|
+
const boxAt = (x: number, y: number): PanelRect => {
|
|
74
|
+
// Measured from the box the press started on, never from the live one:
|
|
75
|
+
// reading it each move would chase the panel as it moves and the travel
|
|
76
|
+
// would compound.
|
|
77
|
+
const dx = x - originX;
|
|
78
|
+
const dy = y - originY;
|
|
79
|
+
return {
|
|
80
|
+
left: start.left + dx,
|
|
81
|
+
top: start.top + dy,
|
|
82
|
+
right: start.right + dx,
|
|
83
|
+
bottom: start.bottom + dy,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onMove = (move: PointerEvent): void => {
|
|
88
|
+
if (
|
|
89
|
+
!dragging &&
|
|
90
|
+
Math.hypot(move.clientX - originX, move.clientY - originY) < DRAG_THRESHOLD
|
|
91
|
+
) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
dragging = true;
|
|
95
|
+
handle.setAttribute("data-dragging", "true");
|
|
96
|
+
options.apply(boxAt(move.clientX, move.clientY), start);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const onUp = (up: PointerEvent): void => {
|
|
100
|
+
window.removeEventListener("pointermove", onMove);
|
|
101
|
+
window.removeEventListener("pointerup", onUp);
|
|
102
|
+
if (!dragging) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
handle.removeAttribute("data-dragging");
|
|
106
|
+
options.commit(boxAt(up.clientX, up.clientY), start);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// The press is the panel's from here: without this the browser starts
|
|
110
|
+
// selecting the title text and the drag leaves a highlight behind it.
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
// Listeners on `window`, not the header: a fast drag outruns the pointer
|
|
113
|
+
// and would otherwise strand the panel mid-move with no pointerup.
|
|
114
|
+
window.addEventListener("pointermove", onMove);
|
|
115
|
+
window.addEventListener("pointerup", onUp);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether the press landed on a control inside the header rather than on the
|
|
121
|
+
* header itself.
|
|
122
|
+
*
|
|
123
|
+
* The composed path rather than `target`, because a control a host slots into
|
|
124
|
+
* the header lives in the light DOM: retargeting reports the host element for
|
|
125
|
+
* it, which matches nothing. Everything below the handle is examined and
|
|
126
|
+
* nothing above it, so a control the header happens to sit inside is not one
|
|
127
|
+
* of ours.
|
|
128
|
+
*/
|
|
129
|
+
function onControl(event: PointerEvent, handle: HTMLElement): boolean {
|
|
130
|
+
const path = event.composedPath();
|
|
131
|
+
return path
|
|
132
|
+
.slice(0, path.indexOf(handle))
|
|
133
|
+
.some((node) => node instanceof Element && node.matches(CONTROLS));
|
|
134
|
+
}
|