@fundar/data-chart-telling 0.0.37 → 0.0.39

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.
@@ -104,25 +104,46 @@
104
104
  /**
105
105
  * Pixel-space collision fix-up for end-point labels: resolves each
106
106
  * label's pixel Y through the live y-scale, then declutters those pixels
107
- * to find how far each needs to move to clear its neighbours (`extraDy`).
108
- * `rank`/`lanesUsed`/`runSize` come from `laneAssignment`, which turns the
109
- * decluttered runs into lane assignments.
107
+ * to find how far each needs to move to clear its neighbours and stay
108
+ * within the plot's own top/bottom edge (`extraDy` applies even to a
109
+ * lone label with no neighbours, if its own value sits close enough to
110
+ * the edge). `rank`/`lanesUsed`/`runSize` come from `laneAssignment`,
111
+ * which turns the decluttered runs into lane assignments.
110
112
  *
111
- * Every label reaches out to the same shared lane column (`reach`/
112
- * `laneReach`), not just the ones in its own cluster, so the whole set of
113
- * end-of-line labels lines up on one horizontal line. A label that didn't
114
- * need to move for collision avoidance still gets a connector to cover
115
- * the resulting gap see the `aligned` gate in the markup below.
113
+ * Horizontal placement (`aligned`/`reach`/`laneReach`/`dir`) is separate:
114
+ * every label reaches out to the same shared lane column, not just the
115
+ * ones in its own cluster, so the whole set of end-of-line labels lines
116
+ * up on one horizontal line. A label gets a connector whenever either of
117
+ * these moved it off its natural position see the markup below.
118
+ *
119
+ * That shared column's direction can itself flip from `dir` to the right,
120
+ * but only ever away from the left: a leftward fan (`dir === -1`) runs
121
+ * into the Y axis's own tick labels once every point sits pinned against
122
+ * that same edge (e.g. a scroll/scrub caller's first visible point), with
123
+ * nowhere to reserve room without growing a margin purely to sit on top of
124
+ * them. The right has no such fixed content by default, so flipping there
125
+ * instead — for every aligned label at once — avoids the collision
126
+ * without needing any extra margin at all. The mirror case is deliberately
127
+ * left alone: a rightward fan's point is *always* pinned at the scale's
128
+ * own right edge by construction (`available` below would read ~0 for it
129
+ * too, same as the left), so the same check would flip the common case
130
+ * right back into the plot over its own lines — margin growth (see
131
+ * `Plot.svelte`'s `labelMargin`) is what actually handles that side.
116
132
  */
117
133
  const declutteredOffsets = $derived.by(() => {
118
- const map = new SvelteMap<string, { extraDy: number; aligned: boolean; rank: number; reach: number; laneReach: number }>();
134
+ const map = new SvelteMap<
135
+ string,
136
+ { extraDy: number; aligned: boolean; rank: number; reach: number; laneReach: number; dir: number }
137
+ >();
119
138
  if (lastPoints.length < 2) return map;
120
139
  const yScale = plot.scales.y?.fn;
121
140
  if (!yScale) return map;
141
+ const xScale = plot.scales.x?.fn;
122
142
  const minGap = Math.max(16, fontSize * 1.4);
123
143
  const items = lastPoints.map(({ series: s, lastRow }) => ({
124
144
  name: s.name,
125
145
  pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
146
+ px: xScale ? Number(xScale(resolveAccessor(s.x)(lastRow))) : undefined,
126
147
  }));
127
148
  const sorted = [...items].sort((a, b) => a.pos - b.pos);
128
149
  // Same content-box source as `watchLabelOverflow`'s `bounds()` above.
@@ -137,6 +158,24 @@
137
158
  const globalLanes = anyGrouped ? Math.max(...ranks.map((r) => r.lanesUsed)) : 1;
138
159
  const reach = elbowGap + (globalLanes - 1) * laneStep + labelGap;
139
160
 
161
+ let effectiveDir = dir;
162
+ if (anyGrouped && dir === -1) {
163
+ // -Infinity (not +Infinity) when the x-scale isn't resolved yet on an
164
+ // early render — defaults to flipped until proven safe not to be,
165
+ // rather than the other way around. Defaulting to "don't flip" here
166
+ // briefly rendered the full leftward fan with no available-space data
167
+ // at all, which `watchLabelOverflow` could catch and lock in via its
168
+ // own monotonic growth before a later render corrected it — flipping
169
+ // right is never wrong even when it turns out to be unnecessary (nothing
170
+ // fixed lives there to run into), so there's no equivalent risk on
171
+ // that side to defend against.
172
+ const roomsLeft = sorted
173
+ .filter((item): item is typeof item & { px: number } => item.px != null)
174
+ .map((item) => item.px - numericMargins.left);
175
+ const availableLeft = roomsLeft.length > 0 ? Math.min(...roomsLeft) : -Infinity;
176
+ if (availableLeft < reach) effectiveDir = 1;
177
+ }
178
+
140
179
  sorted.forEach((item, i) => {
141
180
  map.set(item.name, {
142
181
  extraDy: finals[i] - originals[i],
@@ -144,6 +183,7 @@
144
183
  rank: ranks[i].rank,
145
184
  reach,
146
185
  laneReach: elbowGap + ranks[i].rank * laneStep,
186
+ dir: effectiveDir,
147
187
  });
148
188
  });
149
189
  return map;
@@ -151,22 +191,38 @@
151
191
  </script>
152
192
 
153
193
  <g bind:this={groupEl}>
194
+ <!-- Nothing renders before both scales resolve: `declutteredOffsets` itself
195
+ only needs the y-scale, so an early render with the x-scale still
196
+ missing would otherwise fall back to plain `baseDx`, un-flipped — for a
197
+ long 'end'-anchored label that's a real, if brief, overflow past the
198
+ edge, and `watchLabelOverflow` can catch and lock that in via its own
199
+ monotonic growth before a later, correctly-positioned render arrives. -->
200
+ {#if plot.scales.x?.fn && plot.scales.y?.fn}
154
201
  {#each lastPoints as { series: s, lastRow, color: seriesColor } (s.name)}
155
202
  {@const xFn = resolveAccessor(s.x)}
156
203
  {@const yFn = resolveAccessor(s.y)}
157
204
  {@const offset = declutteredOffsets.get(s.name)}
158
- {@const extraDy = offset?.aligned ? offset.extraDy : 0}
159
- {@const labelDx = offset?.aligned ? dir * offset.reach : baseDx}
205
+ {@const extraDy = offset?.extraDy ?? 0}
206
+ {@const effDir = offset?.aligned ? offset.dir : dir}
207
+ {@const flipped = offset?.aligned && effDir !== dir}
208
+ {@const labelDx = offset?.aligned ? effDir * offset.reach : baseDx}
160
209
  {@const text = formatValue(Number(yFn(lastRow)), s.name)}
161
210
  {@const preferredTA = resolvedTextAnchor ?? defaultTA}
211
+ {@const finalTA = flipped
212
+ ? preferredTA === 'start'
213
+ ? 'end'
214
+ : preferredTA === 'end'
215
+ ? 'start'
216
+ : preferredTA
217
+ : preferredTA}
162
218
  {@const preferredLA = font?.lineAnchor ?? defaultLA}
163
219
  {@const disabled = disabledFor?.(s.name)}
164
220
  {@const disabledStroke = disabledStrokeOverride(disabled)}
165
221
  {@const disabledFill = disabledFillOverride(disabled)}
166
- {#if offset?.aligned && plot.scales.x?.fn && plot.scales.y?.fn}
222
+ {#if offset?.aligned || extraDy !== 0}
167
223
  {@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
168
224
  {@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
169
- {@const laneX = px + dir * offset.laneReach}
225
+ {@const laneX = offset?.aligned ? px + effDir * offset.laneReach : px + labelDx}
170
226
  {@const targetY = py + extraDy}
171
227
  <path
172
228
  d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
@@ -192,7 +248,7 @@
192
248
  text={() => text}
193
249
  dx={labelDx}
194
250
  dy={(font?.dy ?? defaultDy) + extraDy}
195
- textAnchor={preferredTA}
251
+ textAnchor={finalTA}
196
252
  lineAnchor={preferredLA}
197
253
  lineHeight={font?.lineHeight}
198
254
  rotate={font?.rotate}
@@ -209,4 +265,5 @@
209
265
  />
210
266
  </g>
211
267
  {/each}
268
+ {/if}
212
269
  </g>
@@ -103,10 +103,10 @@ function runBiasWeights(down, up, connected, [lo, hi]) {
103
103
  * it lands entirely inside `[lo, hi]`. A run's own relative spacing is
104
104
  * preserved as-is (just translated) whenever it already fits in `hi - lo`;
105
105
  * only a run wider than the available space gets uniformly scaled down
106
- * around its centre first. Lone, unconnected positions pass through
107
- * untouched they were never pushed by `declutter1D` in the first place,
108
- * so clamping one to the bounds would move a label away from the data point
109
- * it's honestly reporting, for no collision-avoidance reason at all.
106
+ * around its centre first. A lone, unconnected position is simply clamped
107
+ * to the nearer bound instead its own value never needed decluttering,
108
+ * but it can still sit close enough to the scale's own edge to overflow on
109
+ * its own.
110
110
  */
111
111
  function fitRunsWithinBounds(positions, connected, [lo, hi]) {
112
112
  const out = [...positions];
@@ -130,6 +130,9 @@ function fitRunsWithinBounds(positions, connected, [lo, hi]) {
130
130
  }
131
131
  }
132
132
  }
133
+ else {
134
+ out[i] = Math.min(Math.max(out[i], lo), hi);
135
+ }
133
136
  i = j + 1;
134
137
  }
135
138
  return out;
@@ -1,3 +1,4 @@
1
+ import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
1
2
  const NO_OVERFLOW = { left: 0, right: 0, top: 0, bottom: 0 };
2
3
  /**
3
4
  * Watches an SVG group's rendered bounding box against the plot's own
@@ -36,12 +37,21 @@ export function watchLabelOverflow(args) {
36
37
  catch {
37
38
  return;
38
39
  }
39
- const overflow = {
40
- left: Math.ceil(Math.max(0, bounds.left - box.x)),
41
- right: Math.ceil(Math.max(0, box.x + box.width - bounds.right)),
42
- top: Math.ceil(Math.max(0, bounds.top - box.y)),
43
- bottom: Math.ceil(Math.max(0, box.y + box.height - bounds.bottom)),
44
- };
40
+ // An empty group (no labels currently rendered, e.g. every series is
41
+ // scrubbed/filtered out of view) reports getBBox() as (0,0,0,0) the
42
+ // SVG origin, not "no content". Reading that literally as a box sitting
43
+ // at (0,0) would report overflow proportional to the *current* margins
44
+ // themselves, which grows them, which remounts this group against the
45
+ // new, larger bounds, which reports overflow again — an unbounded loop
46
+ // with nothing rendered to justify it.
47
+ const overflow = box.width === 0 && box.height === 0
48
+ ? NO_OVERFLOW
49
+ : {
50
+ left: Math.ceil(Math.max(0, bounds.left - box.x)),
51
+ right: Math.ceil(Math.max(0, box.x + box.width - bounds.right)),
52
+ top: Math.ceil(Math.max(0, bounds.top - box.y)),
53
+ bottom: Math.ceil(Math.max(0, box.y + box.height - bounds.bottom))
54
+ };
45
55
  attempt += 1;
46
56
  const stable = lastReading != null &&
47
57
  lastReading.left === overflow.left &&
@@ -85,7 +95,7 @@ export function createLabelMarginTracker(margins) {
85
95
  left: Math.max(measured.left, overflow.left),
86
96
  right: Math.max(measured.right, overflow.right),
87
97
  top: Math.max(measured.top, overflow.top),
88
- bottom: Math.max(measured.bottom, overflow.bottom),
98
+ bottom: Math.max(measured.bottom, overflow.bottom)
89
99
  };
90
100
  if (next.left !== measured.left ||
91
101
  next.right !== measured.right ||
@@ -94,19 +104,27 @@ export function createLabelMarginTracker(margins) {
94
104
  measured = next;
95
105
  }
96
106
  }
107
+ // `measured` is overflow *past* the current margin, not the total margin
108
+ // needed — grown on top of `AUTO_MARGIN_ESTIMATE` (the same fallback
109
+ // svelteplot's own 'auto' sizing is estimated by elsewhere), not the raw
110
+ // overflow alone, or growth would silently replace whatever room 'auto'
111
+ // would have reserved for the axis itself (tick labels, title) with a
112
+ // number that only accounts for the value label. `AUTO_MARGIN_ESTIMATE` is
113
+ // a fixed constant, not `numericMargins`' own already-grown value, so this
114
+ // can't compound into unbounded growth across repeated reports.
97
115
  const resolvedMargins = $derived.by(() => {
98
116
  const base = margins();
99
117
  if (measured.left <= 0 && measured.right <= 0 && measured.top <= 0 && measured.bottom <= 0)
100
118
  return base;
101
119
  const out = { ...base };
102
120
  if (measured.right > 0 && base?.right === undefined)
103
- out.right = measured.right;
121
+ out.right = AUTO_MARGIN_ESTIMATE.right + measured.right;
104
122
  if (measured.left > 0 && base?.left === undefined)
105
- out.left = measured.left;
123
+ out.left = AUTO_MARGIN_ESTIMATE.left + measured.left;
106
124
  if (measured.top > 0 && base?.top === undefined)
107
- out.top = measured.top;
125
+ out.top = AUTO_MARGIN_ESTIMATE.top + measured.top;
108
126
  if (measured.bottom > 0 && base?.bottom === undefined)
109
- out.bottom = measured.bottom;
127
+ out.bottom = AUTO_MARGIN_ESTIMATE.bottom + measured.bottom;
110
128
  return out;
111
129
  });
112
130
  // Bundles `resolvedMargins` with a remount key derived from `measured`,
@@ -117,11 +135,15 @@ export function createLabelMarginTracker(margins) {
117
135
  // must track.
118
136
  const plotProps = $derived.by(() => ({
119
137
  margins: resolvedMargins,
120
- marginRemountKey: JSON.stringify(measured),
138
+ marginRemountKey: JSON.stringify(measured)
121
139
  }));
122
140
  return {
123
141
  report,
124
- get resolvedMargins() { return resolvedMargins; },
125
- get plotProps() { return plotProps; },
142
+ get resolvedMargins() {
143
+ return resolvedMargins;
144
+ },
145
+ get plotProps() {
146
+ return plotProps;
147
+ }
126
148
  };
127
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"