@fusion.dev/helix-headless 0.194.0 → 0.196.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.
@@ -1,4 +1,4 @@
1
- import { depthOrder, ISOMETRIC, pathData, projectionOf, solid, } from '@fusion.dev/helix-core';
1
+ import { depthOrder, hullOf, ISOMETRIC, pathData, projectionOf, solid, } from '@fusion.dev/helix-core';
2
2
  export const ACROSS = Math.sqrt(3) / 2;
3
3
  export const MARGIN = 0.3;
4
4
  export const THICKNESS = 0.25;
@@ -76,7 +76,9 @@ export const reliefOf = (heights, rows, footprint, camera = ISOMETRIC) => {
76
76
  base: undefined,
77
77
  box: { height: 0, width: 0, x: 0, y: 0 },
78
78
  corners: undefined,
79
+ middle: { x: 0, y: 0 },
79
80
  order: [],
81
+ outline: [],
80
82
  prisms,
81
83
  };
82
84
  }
@@ -89,14 +91,67 @@ export const reliefOf = (heights, rows, footprint, camera = ISOMETRIC) => {
89
91
  const xs = points.map((point) => point.x);
90
92
  const ys = points.map((point) => point.y);
91
93
  const [left, top] = [Math.min(...xs), Math.min(...ys)];
94
+ const tallest = Math.max(0, ...heights);
95
+ const [lastRow, firstRow] = [-(deep + MARGIN), MARGIN];
96
+ const front = projection.depth(columns / 2, lastRow) >= projection.depth(columns / 2, firstRow)
97
+ ? lastRow
98
+ : firstRow;
99
+ const outline = [];
100
+ for (const x of [-MARGIN, columns + MARGIN]) {
101
+ for (const y of [lastRow, firstRow]) {
102
+ outline.push(projection.at(x, y, -THICKNESS), projection.at(x, y, tallest));
103
+ }
104
+ }
92
105
  return {
93
106
  base,
94
107
  box: { height: Math.max(...ys) - top, width: Math.max(...xs) - left, x: left, y: top },
95
108
  corners: {
96
- end: projection.at(columns + MARGIN, -(deep + MARGIN), -THICKNESS),
97
- start: projection.at(-MARGIN, -(deep + MARGIN), -THICKNESS),
109
+ end: projection.at(columns + MARGIN, front, -THICKNESS),
110
+ start: projection.at(-MARGIN, front, -THICKNESS),
98
111
  },
112
+ middle: projection.at(columns / 2, -deep / 2, tallest / 2),
99
113
  order: depthOrder(projection, places).map((place) => place.index),
114
+ outline,
100
115
  prisms,
101
116
  };
102
117
  };
118
+ const lowestOver = (hull, left, right) => {
119
+ let lowest = Number.NEGATIVE_INFINITY;
120
+ hull.forEach((from, index) => {
121
+ const to = hull[(index + 1) % hull.length];
122
+ const [a, b] = from.x <= to.x ? [from, to] : [to, from];
123
+ if (b.x < left || a.x > right) {
124
+ return;
125
+ }
126
+ const at = (x) => b.x === a.x ? Math.max(a.y, b.y) : a.y + ((x - a.x) / (b.x - a.x)) * (b.y - a.y);
127
+ lowest = Math.max(lowest, at(Math.max(a.x, left)), at(Math.min(b.x, right)));
128
+ });
129
+ return lowest;
130
+ };
131
+ export const labelPlaces = (outline, corners, sizes, span, gap, lowest = Number.POSITIVE_INFINITY) => {
132
+ const hull = hullOf(outline);
133
+ const across = (left, size) => Math.max(0, Math.min(left, span - size.width));
134
+ const place = (at, toward, size) => {
135
+ const away = Math.sign(toward.x - at.x);
136
+ const wanted = away > 0 ? at.x - size.width : away < 0 ? at.x : at.x - size.width / 2;
137
+ const left = across(wanted, size);
138
+ const top = Math.max(at.y, lowestOver(hull, left, left + size.width)) + gap;
139
+ return { x: left, y: Math.min(top, lowest) };
140
+ };
141
+ const start = place(corners.start, corners.end, sizes.start);
142
+ const end = place(corners.end, corners.start, sizes.end);
143
+ const meet = start.x < end.x + sizes.end.width &&
144
+ end.x < start.x + sizes.start.width &&
145
+ start.y < end.y + sizes.end.height &&
146
+ end.y < start.y + sizes.start.height;
147
+ if (!meet) {
148
+ return { end, start };
149
+ }
150
+ const under = start.y + sizes.start.height + gap / 2;
151
+ if (under <= lowest) {
152
+ return { end: { ...end, y: under }, start };
153
+ }
154
+ const right = corners.end.x >= corners.start.x;
155
+ const beside = right ? start.x + sizes.start.width + gap : start.x - sizes.end.width - gap;
156
+ return { end: { x: across(beside, sizes.end), y: start.y }, start };
157
+ };
@@ -1,6 +1,13 @@
1
1
  import { nothing, type PropertyValues, type TemplateResult } from 'lit';
2
2
  import '../../tooltip/index.js';
3
3
  import { HelixHeadlessHeatmap } from '../../heatmap/root/heatmap-root.js';
4
+ import { type Point } from '../projection.js';
5
+ /** Where a renderer draws the front edge's two ends, and the corners of the box round the relief. */
6
+ type DrawnCorners = {
7
+ end: Point;
8
+ outline?: Point[];
9
+ start: Point;
10
+ };
4
11
  /**
5
12
  * @summary A heatmap drawn as a relief, where each reading stands as tall as its value.
6
13
  *
@@ -35,27 +42,30 @@ import { HelixHeadlessHeatmap } from '../../heatmap/root/heatmap-root.js';
35
42
  *
36
43
  * **The prisms stand on a slab**, the `base`, which reaches a little past the
37
44
  * grid, so the relief reads as one object. The `start` and `end` slots name
38
- * the two ends of the front edge, which is the strip's first and last column:
39
- * the component writes where those corners are on the `footer`, and a
40
- * stylesheet stands each label at its corner. A renderer tells the chart where
41
- * it draws those corners, so the labels stand at them there too, and follow
42
- * them as the view turns.
45
+ * the two ends of the front edge, the long edge nearer the reader, which is
46
+ * the strip's first and last column. The component measures where each label
47
+ * stands under its corner, clear of the drawing, and writes it on the label as
48
+ * `--spatial-heatmap-label-x` and `--spatial-heatmap-label-y`. A renderer
49
+ * tells the chart where it draws those corners, so the labels stand at them
50
+ * there too, and follow them as the view turns.
43
51
  *
44
52
  * **`gap` and `min-cell` set the smallest cell.** The drawing scales as a
45
53
  * whole, so a wider box draws larger cells and the gap grows with them. A box
46
54
  * too narrow for every column at `min-cell` draws the newest that fit.
47
55
  *
48
56
  * **The drawing is SVG and holds still.** It scales with its box through its
49
- * `viewBox`, needs no WebGL, and has no motion to pause. Shift with an arrow
50
- * turns it a step, or tilts it, from the isometric view it starts at.
57
+ * `viewBox`, needs no WebGL, and has no motion to pause. A drag turns it and
58
+ * tilts it, and Shift with an arrow turns it a step, from the isometric view
59
+ * it starts at.
51
60
  * **Under `selection-mode`, the prisms are painted back to front for the
52
61
  * view, and the grid's rows and cells stand beside them in the table's
53
62
  * order**, holding no drawing of their own, so a turned view paints
54
63
  * correctly and a screen reader still reads the grid in order.
55
64
  *
56
65
  * **A renderer can draw it in 3D instead.** Put a box for it in the
57
- * `renderer` slot, and `connectSpatialHeatmap` from `@fusion.dev/helix-plot`
58
- * draws the prisms in it with three.js, lit, in the same tones, in a view
66
+ * `renderer` slot, and `connectSpatialHeatmap` from `@fusion.dev/helix-plot`,
67
+ * given `threeHeatmapDriver` from `@fusion.dev/helix-plot/three`, draws the
68
+ * prisms in it with three.js, lit, in the same tones, in a view
59
69
  * that drags round and zooms. The chart publishes its readings in `field`,
60
70
  * and its own drawing stays under the renderer's, unseen, where a screen
61
71
  * reader and the keys still reach it. Shift with an arrow turns the view,
@@ -100,12 +110,9 @@ import { HelixHeadlessHeatmap } from '../../heatmap/root/heatmap-root.js';
100
110
  * @csspart strip - The box the drawing fills, which takes focus to be read by key. It is named by the host's `aria-labelledby` or `aria-label`, then by `label`, then by the caption of the table.
101
111
  * @csspart top-face - The top of a prism, which carries the reading's tone.
102
112
  *
103
- * @cssproperty --spatial-heatmap-depth - How tall the drawing is for its width. The component writes it on the footer, and a stylesheet that stands the `start` and `end` labels at their corners reads it.
104
- * @cssproperty --spatial-heatmap-end-x - Where the front edge ends, under the newest column, across the drawing, from 0 at its inline start to 1. The component writes it on the footer, and a stylesheet that stands the `end` label there reads it.
105
- * @cssproperty --spatial-heatmap-end-y - How far down the drawing the front edge ends, under the newest column, as a share of the drawing's width: its whole depth in the chart's own drawing, and wherever a renderer draws it. The component writes it on the footer, and a stylesheet that stands the `end` label there reads it.
113
+ * @cssproperty --spatial-heatmap-label-x - Where the `start` or the `end` label stands clear of the drawing, from the footer's inline start, in pixels. The component measures it and writes it on the label, with `data-placed`, as the view turns.
114
+ * @cssproperty --spatial-heatmap-label-y - Where the `start` or the `end` label stands clear of the drawing, from the footer's top, in pixels. The component measures it and writes it on the label, with `data-placed`, as the view turns.
106
115
  * @cssproperty --spatial-heatmap-span - How many cells the drawing spans across: its columns and its rows together. The component writes it on the strip, and a stylesheet that caps the cell size reads it.
107
- * @cssproperty --spatial-heatmap-start-x - Where the front edge starts, under the first column, across the drawing, from 0 at its inline start to 1: 0 in the chart's own drawing, and wherever a renderer draws it. The component writes it on the footer, and a stylesheet that stands the `start` label there reads it.
108
- * @cssproperty --spatial-heatmap-start-y - How far down the drawing the front edge starts, under the first column, as a share of the drawing's width. The component writes it on the footer, and a stylesheet that stands the `start` label there reads it.
109
116
  *
110
117
  * @cssstate drawn - While a renderer has drawn the relief, and the chart's own drawing stays unseen under it.
111
118
  */
@@ -127,8 +134,28 @@ export declare class HelixHeadlessSpatialHeatmap extends HelixHeadlessHeatmap {
127
134
  relief: number;
128
135
  /** The prisms drawn on the last render, which the tooltip is placed against. */
129
136
  private shown;
130
- /** Where the chart's own drawing is seen from, which Shift with an arrow turns. */
137
+ /** Where the chart's own drawing is seen from, which a drag or Shift with an arrow turns. */
131
138
  private camera;
139
+ /** What places the first and last labels again when one of them, or the footer, changes size. */
140
+ private labelSizes;
141
+ connectedCallback(): void;
142
+ disconnectedCallback(): void;
143
+ /**
144
+ * Places the labels again whenever one of them or the footer changes size,
145
+ * as a webfont arriving after the first measure changes a label's width,
146
+ * and with it how far down the front edge reaches over it. Once the footer
147
+ * is rendered, and guarded because Lit SSR provides no `ResizeObserver`.
148
+ */
149
+ private observeLabels;
150
+ /** A drag across the chart's own drawing, which turns its view. */
151
+ private readonly drag;
152
+ private readonly onDrawingDown;
153
+ private readonly onDrawingMove;
154
+ private readonly onDrawingUp;
155
+ /** A tap shows the prism under it, unless the touch was a drag, which turned the view. */
156
+ private readonly onStripUp;
157
+ /** A pointer passing over a prism shows it, unless it is turning the view. */
158
+ private readonly onPrismOver;
132
159
  /** Whether a renderer has drawn the relief, which its own drawing then stays under. */
133
160
  private rendered;
134
161
  /** Where the renderer draws the reading, which the tooltip points at. */
@@ -182,16 +209,7 @@ export declare class HelixHeadlessSpatialHeatmap extends HelixHeadlessHeatmap {
182
209
  * last place they had. The `start` and `end` labels stand there. Called by
183
210
  * the connector.
184
211
  */
185
- placeCorners(at: {
186
- end: {
187
- x: number;
188
- y: number;
189
- };
190
- start: {
191
- x: number;
192
- y: number;
193
- };
194
- } | undefined): void;
212
+ placeCorners(at: DrawnCorners | undefined): void;
195
213
  protected updated(changed: PropertyValues): void;
196
214
  /**
197
215
  * Shift with an arrow turns the view, the chart's own drawing or a
@@ -223,11 +241,14 @@ export declare class HelixHeadlessSpatialHeatmap extends HelixHeadlessHeatmap {
223
241
  */
224
242
  private renderBase;
225
243
  /**
226
- * Where the front edge's two ends stand, for the labels that name them.
227
- * **As shares of the drawing's width**, the one length a percentage margin
228
- * is measured against, so a sheet places a label with no height to read.
244
+ * Stands the first and last labels under their ends of the front edge,
245
+ * clear of the drawing, as the page lays them out: on each label,
246
+ * `--spatial-heatmap-label-x` from the footer's inline start and
247
+ * `--spatial-heatmap-label-y` from its top, and `data-placed` once it has
248
+ * a place. **Measured**, because a label is the page's type and the
249
+ * drawing scales with its box, so neither size is known before layout.
229
250
  */
230
- private cornersStyle;
251
+ private placeLabels;
231
252
  /**
232
253
  * The grid's rows, one for each column of the strip, and its cells in the
233
254
  * table's order, each named by its row and saying whether it is selected,
@@ -246,3 +267,4 @@ declare global {
246
267
  'helix-headless-spatial-heatmap': HelixHeadlessSpatialHeatmap;
247
268
  }
248
269
  }
270
+ export {};
@@ -5,8 +5,9 @@ import { property, state } from 'lit/decorators.js';
5
5
  import '../../tooltip/index.js';
6
6
  import { fits, toneOf } from '../../heatmap/root/buckets.js';
7
7
  import { HelixHeadlessHeatmap } from '../../heatmap/root/heatmap-root.js';
8
- import { turned } from '../../spatial-scatter-chart/view.js';
9
- import { ACROSS, heightsOf, highestOf, reliefOf, } from '../projection.js';
8
+ import { DragTurn, turned } from '../../spatial-scatter-chart/view.js';
9
+ import { ACROSS, heightsOf, highestOf, labelPlaces, reliefOf, } from '../projection.js';
10
+ const LABEL_GAP = 4;
10
11
  const TURNS = {
11
12
  ArrowDown: { across: 0, up: -1 },
12
13
  ArrowLeft: { across: -1, up: 0 },
@@ -20,6 +21,29 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
20
21
  this.relief = 2;
21
22
  this.shown = undefined;
22
23
  this.camera = ISOMETRIC;
24
+ this.labelSizes = undefined;
25
+ this.drag = new DragTurn((next) => {
26
+ this.camera = next(this.camera);
27
+ }, () => this.requestUpdate());
28
+ this.onDrawingDown = (event) => {
29
+ this.drag.down(event);
30
+ };
31
+ this.onDrawingMove = (event) => {
32
+ this.drag.move(event);
33
+ };
34
+ this.onDrawingUp = (event) => {
35
+ this.drag.up(event);
36
+ };
37
+ this.onStripUp = (event) => {
38
+ if (!this.drag.dragged) {
39
+ this.onPointerUp(event);
40
+ }
41
+ };
42
+ this.onPrismOver = (event) => {
43
+ if (!this.drag.moving) {
44
+ this.onPointerOver(event);
45
+ }
46
+ };
23
47
  this.rendered = false;
24
48
  this.told = { at: -1, field: '' };
25
49
  this.onTurnKeydown = (event) => {
@@ -45,6 +69,25 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
45
69
  }
46
70
  static { this.words = 'helix-spatial-heatmap'; }
47
71
  static { this.markRules = [MIRROR_RULE]; }
72
+ connectedCallback() {
73
+ super.connectedCallback();
74
+ this.observeLabels();
75
+ }
76
+ disconnectedCallback() {
77
+ super.disconnectedCallback();
78
+ this.labelSizes?.disconnect();
79
+ this.labelSizes = undefined;
80
+ }
81
+ observeLabels() {
82
+ const boxes = ['footer', 'start', 'end'].map((name) => this.partNamed(name));
83
+ if (this.labelSizes || typeof ResizeObserver === 'undefined' || boxes.includes(null)) {
84
+ return;
85
+ }
86
+ this.labelSizes = new ResizeObserver(() => this.placeLabels());
87
+ for (const box of boxes) {
88
+ this.labelSizes.observe(box);
89
+ }
90
+ }
48
91
  get field() {
49
92
  const cells = this.drawn;
50
93
  const rows = this.rowCount;
@@ -90,6 +133,7 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
90
133
  }
91
134
  updated(changed) {
92
135
  super.updated(changed);
136
+ this.placeLabels();
93
137
  const field = JSON.stringify(this.field);
94
138
  if (field !== this.told.field) {
95
139
  this.told.field = field;
@@ -127,7 +171,8 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
127
171
  const grid = readable && this.selectionMode !== undefined;
128
172
  const keys = this.selectionMode ? this.keysOf(cells) : [];
129
173
  const chosen = new Set(this.selectionMode ? commaList(this.value) : []);
130
- const relief = this.drawnRelief();
174
+ const fits = this.drawnRelief();
175
+ const relief = { ...fits, box: this.drag.frame(fits.box, fits.middle) };
131
176
  this.shown = relief;
132
177
  const at = this.pointer.at;
133
178
  const prisms = relief.order.map((index) => this.renderPrism(relief.prisms[index], at === index, chosen.has(keys[index] ?? ''), grid, toneOf(cells[index] ?? { value: undefined }, bounds, palette)));
@@ -141,6 +186,11 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
141
186
  style=${this.rendered ? 'opacity: 0; pointer-events: none' : nothing}
142
187
  viewBox="${box.x} ${box.y} ${box.width} ${box.height}"
143
188
  xmlns="http://www.w3.org/2000/svg"
189
+ @click=${this.drag.click}
190
+ @pointercancel=${this.onDrawingUp}
191
+ @pointerdown=${this.onDrawingDown}
192
+ @pointermove=${this.onDrawingMove}
193
+ @pointerup=${this.onDrawingUp}
144
194
  >${relief.base ? this.renderBase(relief.base) : nothing}${prisms}${grid ? this.renderGridRows(cells.length, keys, chosen) : nothing}</svg>`
145
195
  : nothing;
146
196
  return html `<div
@@ -155,12 +205,12 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
155
205
  tabindex=${readable ? '0' : nothing}
156
206
  @click=${this.onClick}
157
207
  @keydown=${this.onTurnKeydown}
158
- @pointerover=${this.rendered ? undefined : this.onPointerOver}
208
+ @pointerover=${this.rendered ? undefined : this.onPrismOver}
159
209
  @pointerleave=${this.onPointerLeave}
160
- @pointerup=${this.rendered ? undefined : this.onPointerUp}
210
+ @pointerup=${this.rendered ? undefined : this.onStripUp}
161
211
  ><slot name="renderer"></slot>${drawing}${this.renderTooltip(grid)}</div>
162
212
  ${cells.length === 0 ? this.renderStatus() : nothing}
163
- <div part="footer" style=${this.cornersStyle(relief)}>
213
+ <div part="footer">
164
214
  <span part="start"><slot name="start"></slot></span>
165
215
  <span part="summary"><slot name="summary"></slot></span>
166
216
  <span part="end"><slot name="end"></slot></span>
@@ -180,28 +230,57 @@ let HelixHeadlessSpatialHeatmap = class HelixHeadlessSpatialHeatmap extends Heli
180
230
  renderBase(base) {
181
231
  return svg `<g aria-hidden="true" part="base"><path d=${base.left} part="base-left"></path><path d=${base.right} part="base-right"></path><path d=${base.top} part="base-top"></path></g>`;
182
232
  }
183
- cornersStyle(relief) {
184
- const { box, corners } = relief;
185
- if (!corners || box.width <= 0) {
186
- return nothing;
233
+ placeLabels() {
234
+ const footer = this.partNamed('footer');
235
+ const [end, start] = [this.partNamed('end'), this.partNamed('start')];
236
+ const relief = this.shown;
237
+ if (!footer || !end || !start || !relief?.corners) {
238
+ return;
239
+ }
240
+ const labels = { end, start };
241
+ this.observeLabels();
242
+ const frame = footer.getBoundingClientRect();
243
+ if (frame.width <= 0) {
244
+ return;
245
+ }
246
+ let drawn = { ...relief.corners, outline: relief.outline };
247
+ let page;
248
+ let lowest = Number.POSITIVE_INFINITY;
249
+ if (this.rendered) {
250
+ const strip = this.partNamed('strip')?.getBoundingClientRect();
251
+ if (!this.corners?.outline || !strip) {
252
+ return;
253
+ }
254
+ drawn = this.corners;
255
+ lowest = strip.bottom - frame.top + LABEL_GAP;
256
+ page = (point) => ({
257
+ x: strip.left + point.x * strip.width - frame.left,
258
+ y: strip.top + point.y * strip.height - frame.top,
259
+ });
260
+ }
261
+ else {
262
+ const matrix = this.partNamed('drawing')?.getScreenCTM?.();
263
+ if (!matrix) {
264
+ return;
265
+ }
266
+ page = (point) => ({
267
+ x: matrix.a * point.x + matrix.c * point.y + matrix.e - frame.left,
268
+ y: matrix.b * point.x + matrix.d * point.y + matrix.f - frame.top,
269
+ });
270
+ }
271
+ const sizeOf = (label) => {
272
+ const { height, width } = label.getBoundingClientRect();
273
+ return { height, width };
274
+ };
275
+ const sizes = { end: sizeOf(labels.end), start: sizeOf(labels.start) };
276
+ const placed = labelPlaces((drawn.outline ?? []).map(page), { end: page(drawn.end), start: page(drawn.start) }, sizes, frame.width, LABEL_GAP, lowest);
277
+ for (const key of ['start', 'end']) {
278
+ const at = placed[key];
279
+ const inline = isRtl(this) ? frame.width - at.x - sizes[key].width : at.x;
280
+ labels[key].style.setProperty('--spatial-heatmap-label-x', `${Math.round(inline)}px`);
281
+ labels[key].style.setProperty('--spatial-heatmap-label-y', `${Math.round(at.y)}px`);
282
+ labels[key].toggleAttribute('data-placed', true);
187
283
  }
188
- const share = (value) => Math.round(value * 10000) / 10000;
189
- const depth = box.height / box.width;
190
- const drawn = this.rendered ? this.corners : undefined;
191
- const across = (x) => (isRtl(this) ? 1 - x : x);
192
- const start = drawn
193
- ? { x: across(drawn.start.x), y: drawn.start.y * depth }
194
- : { x: 0, y: (corners.start.y - box.y) / box.width };
195
- const end = drawn
196
- ? { x: across(drawn.end.x), y: drawn.end.y * depth }
197
- : { x: (corners.end.x - box.x) / box.width, y: depth };
198
- return [
199
- `--spatial-heatmap-depth: ${share(depth)}`,
200
- `--spatial-heatmap-end-x: ${share(end.x)}`,
201
- `--spatial-heatmap-end-y: ${share(end.y)}`,
202
- `--spatial-heatmap-start-x: ${share(start.x)}`,
203
- `--spatial-heatmap-start-y: ${share(start.y)}`,
204
- ].join('; ');
205
284
  }
206
285
  renderGridRows(count, keys, chosen) {
207
286
  const size = this.rowCount === 1 ? count : this.rowCount;
@@ -14,11 +14,12 @@ declare const HelixHeadlessSpatialScatterChart_base: typeof HelixElement & (new
14
14
  *
15
15
  * **Helix draws the frame, and a renderer draws the points in 3D.** Put a
16
16
  * box for the renderer inside the chart, and `connectSpatialScatterChart` from
17
- * `@fusion.dev/helix-plot` draws the points in it with three.js and keeps it
18
- * in step with the chart. Until a renderer has drawn, and where none is
17
+ * `@fusion.dev/helix-plot`, given `threeScatterDriver` from
18
+ * `@fusion.dev/helix-plot/three`, draws the points in it with three.js and
19
+ * keeps it in step with the chart. Until a renderer has drawn, and where none is
19
20
  * connected, the chart draws the points in 3D itself, in SVG, from the same
20
- * place the renderer's camera starts: a still picture the server, a print,
21
- * and a page without WebGL all show, which Shift with an arrow turns.
21
+ * place the renderer's camera starts: a picture a print and a page without
22
+ * WebGL show too, which a drag or Shift with an arrow turns.
22
23
  *
23
24
  * **The table is the reading.** Every number is in it, and screen readers
24
25
  * read it. The `summary` part names the three measures and the number of
@@ -121,8 +122,10 @@ export declare class HelixHeadlessSpatialScatterChart extends HelixHeadlessSpati
121
122
  summaryLabel: string;
122
123
  /** Whether a renderer has drawn the points, which hides the chart's own drawing. */
123
124
  private rendered;
124
- /** Where the chart's own drawing is seen from, which Shift with an arrow turns. */
125
+ /** Where the chart's own drawing is seen from, which a drag or Shift with an arrow turns. */
125
126
  private camera;
127
+ /** A drag across the chart's own drawing, which turns its view. */
128
+ private readonly drag;
126
129
  /** Each point where the chart's own drawing put it, which the pointer finds. */
127
130
  private placed;
128
131
  /** Where the renderer draws the reading, which the tooltip points at. */
@@ -139,6 +142,8 @@ export declare class HelixHeadlessSpatialScatterChart extends HelixHeadlessSpati
139
142
  * left out. A renderer reads it.
140
143
  */
141
144
  get field(): PlotField;
145
+ /** The field last read, and what it was read from. */
146
+ private memo;
142
147
  /** The point the keys or the pointer rest on, as its place in `field.points`, or `-1`. */
143
148
  get active(): number;
144
149
  /** The number of points, which the keys walk. */
@@ -173,8 +178,14 @@ export declare class HelixHeadlessSpatialScatterChart extends HelixHeadlessSpati
173
178
  private readonly onPlotKeydown;
174
179
  /** A mouse or a pen over the chart's own drawing puts the reading on the point nearest it. */
175
180
  private readonly onDrawingMove;
181
+ private readonly onDrawingDown;
182
+ /** A press the browser took back, such as for a scroll, which ends a drag as a release does. */
183
+ private readonly onDrawingCancel;
176
184
  private readonly onDrawingLeave;
177
- /** A tap is a touch reader's hover: it shows the point nearest it until a tap lands elsewhere. */
185
+ /**
186
+ * A tap is a touch reader's hover, unless it was a drag, which turned the
187
+ * view: it shows the point nearest it until a tap lands elsewhere.
188
+ */
178
189
  private readonly onDrawingTap;
179
190
  protected render(): TemplateResult;
180
191
  /**
@@ -4,7 +4,7 @@ import { html, nothing, svg } from 'lit';
4
4
  import { property, state } from 'lit/decorators.js';
5
5
  import '../../tooltip/index.js';
6
6
  import { fieldOf } from '../field.js';
7
- import { boxView, drawBox, inBox, nearestTo, SPATIAL_VIEW, turned, } from '../view.js';
7
+ import { boxView, DragTurn, drawBox, inBox, nearestTo, SPATIAL_VIEW, turned, } from '../view.js';
8
8
  const SERIES = 24;
9
9
  const TURNS = {
10
10
  ArrowDown: { across: 0, up: -1 },
@@ -20,6 +20,9 @@ let HelixHeadlessSpatialScatterChart = class HelixHeadlessSpatialScatterChart ex
20
20
  this.summaryLabel = '{count, plural, one {# point} other {# points}}: {x} across, {y} up, and {z} in depth.';
21
21
  this.rendered = false;
22
22
  this.camera = SPATIAL_VIEW;
23
+ this.drag = new DragTurn((next) => {
24
+ this.camera = next(this.camera);
25
+ }, () => this.requestUpdate());
23
26
  this.placed = [];
24
27
  this.last = undefined;
25
28
  this.told = -1;
@@ -37,18 +40,30 @@ let HelixHeadlessSpatialScatterChart = class HelixHeadlessSpatialScatterChart ex
37
40
  this.dispatchEvent(new HelixPlotTurnEvent(turn));
38
41
  };
39
42
  this.onDrawingMove = (event) => {
43
+ if (this.drag.move(event)) {
44
+ return;
45
+ }
40
46
  if (event.pointerType === 'touch') {
41
47
  return;
42
48
  }
43
49
  const drawing = event.currentTarget;
44
50
  this.requestActive(nearestTo(drawing, event, this.placed), event.pointerType === 'pen' ? 'pen' : 'mouse');
45
51
  };
52
+ this.onDrawingDown = (event) => {
53
+ this.drag.down(event);
54
+ };
55
+ this.onDrawingCancel = (event) => {
56
+ this.drag.up(event);
57
+ };
46
58
  this.onDrawingLeave = (event) => {
47
59
  if (event.pointerType !== 'touch') {
48
60
  this.requestActive(-1, 'mouse');
49
61
  }
50
62
  };
51
63
  this.onDrawingTap = (event) => {
64
+ if (this.drag.up(event)) {
65
+ return;
66
+ }
52
67
  if (event.pointerType === 'touch') {
53
68
  this.requestActive(nearestTo(event.currentTarget, event, this.placed, 24), 'touch');
54
69
  }
@@ -56,7 +71,15 @@ let HelixHeadlessSpatialScatterChart = class HelixHeadlessSpatialScatterChart ex
56
71
  }
57
72
  static { this.words = 'helix-spatial-scatter-chart'; }
58
73
  get field() {
59
- return fieldOf(this.sourceRows, this.plottedColumns, this.groupBy);
74
+ const rows = this.sourceRows;
75
+ if (this.memo?.rows !== rows || this.memo.groupBy !== this.groupBy) {
76
+ this.memo = {
77
+ field: fieldOf(rows, this.plottedColumns, this.groupBy),
78
+ groupBy: this.groupBy,
79
+ rows,
80
+ };
81
+ }
82
+ return this.memo.field;
60
83
  }
61
84
  get active() {
62
85
  return this.pointer.at;
@@ -95,7 +118,7 @@ let HelixHeadlessSpatialScatterChart = class HelixHeadlessSpatialScatterChart ex
95
118
  updated(changed) {
96
119
  super.updated(changed);
97
120
  const field = this.field;
98
- if (!this.last || JSON.stringify(field) !== JSON.stringify(this.last)) {
121
+ if (field !== this.last) {
99
122
  this.last = field;
100
123
  this.dispatchEvent(new HelixPlotChangeEvent({ kind: 'data' }));
101
124
  }
@@ -142,13 +165,16 @@ let HelixHeadlessSpatialScatterChart = class HelixHeadlessSpatialScatterChart ex
142
165
  this.placed = places;
143
166
  const painted = [...places].sort((one, other) => one.depth - other.depth);
144
167
  const active = places[at];
145
- const { bounds } = view;
168
+ const bounds = this.drag.frame(view.bounds, view.on([0, 0, 0]));
146
169
  return html `<div aria-hidden="true" dir="ltr" ?hidden=${this.rendered} part="fallback">
147
170
  <svg
148
171
  part="drawing"
149
172
  preserveAspectRatio="xMidYMid meet"
150
173
  viewBox="${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}"
151
174
  xmlns="http://www.w3.org/2000/svg"
175
+ @click=${this.drag.click}
176
+ @pointercancel=${this.onDrawingCancel}
177
+ @pointerdown=${this.onDrawingDown}
152
178
  @pointerleave=${this.onDrawingLeave}
153
179
  @pointermove=${this.onDrawingMove}
154
180
  @pointerup=${this.onDrawingTap}
@@ -91,3 +91,58 @@ export interface Placed {
91
91
  * drawing is shown.
92
92
  */
93
93
  export declare const nearestTo: (drawing: SVGSVGElement | null | undefined, event: PointerEvent, places: readonly Placed[], reach?: number) => number;
94
+ /** The part of the page a drawing shows, as its `viewBox`: its left, top, width, and height. */
95
+ export interface Frame {
96
+ height: number;
97
+ width: number;
98
+ x: number;
99
+ y: number;
100
+ }
101
+ /**
102
+ * A drag across a spatial chart's own drawing, which turns its view as the
103
+ * pointer moves: across turns it, and down tilts it. **A press that moves
104
+ * less than a few pixels is a press**, so a click still puts the reading on
105
+ * a point, and the click that ends a drag is the drag's: `dragged` says so,
106
+ * for the chart to keep it from pressing anything.
107
+ *
108
+ * **The drawing turns in place.** Through a drag, `frame` holds the drawing
109
+ * at the size it had when the drag began, with the middle of the data where
110
+ * it stood, as the renderer's camera turns round its target. The drawing
111
+ * may reach past its edges as it turns, and nothing on the page moves. Once
112
+ * the drag ends, it settles into the frame that fits it.
113
+ *
114
+ * A touch turns the view when it moves across, and the page's stylesheet
115
+ * leaves a touch moving up and down to scroll the page.
116
+ */
117
+ export declare class DragTurn {
118
+ private readonly turn;
119
+ private readonly redraw;
120
+ private held;
121
+ /** The frame and the middle of the data, as the last render drew them. */
122
+ private last;
123
+ /** The frame held through a drag, and where the middle of the data stands in it. */
124
+ private kept;
125
+ /** The frame a drag ended on, and when it ended, while the drawing settles. */
126
+ private settling;
127
+ /** Whether the press that ended last was a drag. */
128
+ dragged: boolean;
129
+ /**
130
+ * `turn` moves the view, and `redraw` asks for a render, which `frame`
131
+ * needs once a drag ends and on each step as the drawing settles.
132
+ */
133
+ constructor(turn: (next: (camera: Camera) => Camera) => void, redraw: () => void);
134
+ /** Whether a drag is moving the view now. */
135
+ get moving(): boolean;
136
+ down(event: PointerEvent): void;
137
+ /** Turns the view for a drag, and says whether the event was one. */
138
+ move(event: PointerEvent): boolean;
139
+ /** Ends a press, and says whether it was a drag. */
140
+ up(event: PointerEvent): boolean;
141
+ /**
142
+ * The frame to draw in, given the one that fits the drawing now and where
143
+ * the middle of the data stands on the page. Called on every render.
144
+ */
145
+ frame(fits: Frame, middle: PagePoint): Frame;
146
+ /** Keeps the click that ends a drag from pressing a reading. */
147
+ readonly click: (event: Event) => void;
148
+ }