@crouton-kit/capture 1.4.2 → 1.4.10

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.
Files changed (110) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -13,15 +13,6 @@ export interface JankFrame {
13
13
  readonly tMs: number;
14
14
  }
15
15
 
16
- export interface DroppedFrameFact {
17
- readonly afterFrame: number;
18
- readonly beforeFrame: number;
19
- readonly startMs: number;
20
- readonly endMs: number;
21
- readonly intervalMs: number;
22
- readonly estimatedDroppedFrames: number;
23
- }
24
-
25
16
  export type LongTaskTimingDomain = 'recorder-performance' | 'trace-relative-first-event' | 'unavailable';
26
17
 
27
18
  export interface LongTaskFact {
@@ -32,8 +23,6 @@ export interface LongTaskFact {
32
23
  readonly durationMs: number;
33
24
  /** Null when no synchronized recorder-relative observer baseline was retained. */
34
25
  readonly endMs: number | null;
35
- /** Null when task/frame timing is incomparable or unavailable. */
36
- readonly overlapsDroppedFrames: readonly number[] | null;
37
26
  }
38
27
 
39
28
  export interface LayoutShiftRectFact {
@@ -57,7 +46,7 @@ export interface LayoutShiftFact {
57
46
  readonly rects: readonly LayoutShiftRectFact[];
58
47
  }
59
48
 
60
- export type JankCount = 'dropped-frames' | 'long-task-records' | 'layout-shift-records';
49
+ export type JankCount = 'long-task-records' | 'layout-shift-records';
61
50
 
62
51
  export interface ArtifactLossFact {
63
52
  readonly kind: string;
@@ -69,15 +58,13 @@ export interface ArtifactLossFact {
69
58
 
70
59
  export interface MotionJankAnalysis {
71
60
  readonly frameCount: number;
72
- readonly cadenceMs: number | null;
73
61
  readonly missingFrameSampleCount: number;
74
- readonly droppedFrames: readonly DroppedFrameFact[];
75
- readonly droppedFrameCount: number;
76
- readonly droppedFramesIncomplete: boolean;
77
62
  readonly longTasks: readonly LongTaskFact[];
78
63
  readonly longTasksIncomplete: boolean;
79
64
  readonly layoutShifts: readonly LayoutShiftFact[];
80
65
  readonly layoutShiftsIncomplete: boolean;
66
+ /** Buffered PerformanceObserver entries that predate the recorder arm baseline and are excluded from result records. */
67
+ readonly preRecordingObserverEntriesExcluded: number;
81
68
  readonly artifactLoss: readonly ArtifactLossFact[];
82
69
  readonly frameTimestampUncertainty: '±frame' | 'unavailable';
83
70
  readonly timingNote: string;
@@ -202,13 +189,6 @@ function inferredRectFacts(shiftAtMs: number, frames: readonly { tMs: number; el
202
189
  return { rects, beforeFrameMs: before.tMs, afterFrameMs: after.tMs };
203
190
  }
204
191
 
205
- /** The lower quartile keeps a frequent long interval from redefining the nominal frame cadence. */
206
- function stableLowCadence(intervals: readonly number[]): number | null {
207
- if (intervals.length < 2) return null;
208
- const sorted = [...intervals].sort((a, b) => a - b);
209
- return sorted[Math.floor((sorted.length - 1) * 0.25)] ?? null;
210
- }
211
-
212
192
  function missingFrameSamples(frames: readonly JankFrame[]): number {
213
193
  let missing = 0;
214
194
  for (let i = 1; i < frames.length; i++) {
@@ -257,12 +237,12 @@ function artifactLoss(events: readonly unknown[]): ArtifactLossFact[] {
257
237
  case 'error': {
258
238
  const lower = (message ?? '').toLowerCase();
259
239
  const affectedCounts: JankCount[] = lower.includes('rect sample')
260
- ? ['dropped-frames']
240
+ ? []
261
241
  : lower.includes('observer')
262
242
  ? ['long-task-records', 'layout-shift-records']
263
243
  : lower.includes('trace')
264
244
  ? ['long-task-records']
265
- : ['dropped-frames', 'long-task-records', 'layout-shift-records'];
245
+ : ['long-task-records', 'layout-shift-records'];
266
246
  facts.push({ kind: event.kind, count, reason, message, affectedCounts });
267
247
  break;
268
248
  }
@@ -280,42 +260,29 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
280
260
  .filter((item): item is { item: UnknownRecord; tMs: number } => item.tMs !== null)
281
261
  .sort((a, b) => a.tMs - b.tMs);
282
262
  const frames: JankFrame[] = frameRecords.map(({ item, tMs }, index) => ({ frame: finite(item.frame) ?? index, tMs }));
283
- const intervals = frames.slice(1).map((frame, index) => frame.tMs - frames[index].tMs).filter((interval) => interval > 0);
284
- const cadenceMs = stableLowCadence(intervals);
285
263
  const missingFrameSampleCount = missingFrameSamples(frames);
286
- const droppedFrames: DroppedFrameFact[] = [];
287
- if (cadenceMs !== null && cadenceMs > 0) {
288
- for (let i = 1; i < frames.length; i++) {
289
- const intervalMs = frames[i].tMs - frames[i - 1].tMs;
290
- const estimatedDroppedFrames = Math.max(0, Math.round(intervalMs / cadenceMs) - 1);
291
- if (estimatedDroppedFrames > 0) droppedFrames.push({ afterFrame: frames[i].frame, beforeFrame: frames[i - 1].frame, startMs: frames[i - 1].tMs, endMs: frames[i].tMs, intervalMs, estimatedDroppedFrames });
292
- }
293
- }
294
264
 
295
265
  const traceEvents = flattenTraceEvents(input.events);
296
266
  const observedTraceBaselineUs = traceEvents.map((event) => finite(event.ts)).filter((ts): ts is number => ts !== undefined).sort((a, b) => a - b)[0];
297
267
  const observerEntries = observerRecords(input.events);
298
- // The two distinct causes of unavailable observer timing, tracked separately so the
299
- // user-visible note attributes each factually instead of blaming navigation for both.
300
- let observerNavGapUnavailable = false; // null recorder-relative time: no synchronized baseline (post-navigation-gap).
301
- let observerPreArmUnavailable = false; // negative recorder-relative time: buffered entry whose document timestamp predates the arm baseline.
268
+ let observerNavGapUnavailable = false;
269
+ let preRecordingObserverEntriesExcluded = 0;
302
270
  const longTasks: LongTaskFact[] = [];
303
271
  for (const { item, timingAvailable } of observerEntries) {
304
272
  if (item.entryType !== 'longtask') continue;
305
273
  const durationMs = finite(item.duration);
306
274
  if (durationMs === undefined) continue;
307
- // A negative recorder-relative start comes from a buffered entry whose document
308
- // time origin predates the recorder arm baseline; it has no valid recorder-relative
309
- // baseline, so route it (like a null or post-navigation-gap start) to 'unavailable'
310
- // rather than emitting a negative time or silently dropping it.
311
275
  const startMs = timingAvailable ? observerToPerformanceMs(item, input.markers) : null;
312
- if (startMs === null || startMs < 0) {
313
- if (startMs === null) observerNavGapUnavailable = true;
314
- else observerPreArmUnavailable = true;
315
- longTasks.push({ source: 'observer', timingDomain: 'unavailable', startMs: null, durationMs, endMs: null, overlapsDroppedFrames: null });
276
+ if (startMs === null) {
277
+ observerNavGapUnavailable = true;
278
+ longTasks.push({ source: 'observer', timingDomain: 'unavailable', startMs: null, durationMs, endMs: null });
316
279
  continue;
317
280
  }
318
- longTasks.push(longTask('observer', 'recorder-performance', startMs, durationMs, droppedFrames));
281
+ if (startMs < 0) {
282
+ preRecordingObserverEntriesExcluded++;
283
+ continue;
284
+ }
285
+ longTasks.push(longTask('observer', 'recorder-performance', startMs, durationMs));
319
286
  }
320
287
  for (const trace of traceEvents) {
321
288
  const name = typeof trace.name === 'string' ? trace.name : '';
@@ -323,7 +290,7 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
323
290
  const timestampUs = finite(trace.ts);
324
291
  if (!/(longtask|run(task|microtasks)|task)/i.test(name) || durationMs === undefined || durationMs < 50_000 || timestampUs === undefined) continue;
325
292
  const timing = traceTime(timestampUs, input.markers, observedTraceBaselineUs);
326
- if (timing !== null) longTasks.push(longTask('trace', timing.domain, timing.tMs, durationMs / 1000, droppedFrames));
293
+ if (timing !== null) longTasks.push(longTask('trace', timing.domain, timing.tMs, durationMs / 1000));
327
294
  }
328
295
 
329
296
  const frameElementRecords = frameRecords.map(({ item, tMs }) => ({ tMs, elements: (Array.isArray(item.elements) ? item.elements : []).map(record).filter((v): v is UnknownRecord => v !== null) }));
@@ -332,14 +299,9 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
332
299
  if (item.entryType !== 'layout-shift') continue;
333
300
  const value = finite(item.value);
334
301
  if (value === undefined) continue;
335
- // A negative recorder-relative time comes from a buffered entry whose document
336
- // time origin predates the recorder arm baseline; it has no valid recorder-relative
337
- // baseline, so route it (like a null or post-navigation-gap time) to 'unavailable'
338
- // rather than emitting a negative time or silently dropping it.
339
302
  const tMs = timingAvailable ? observerToPerformanceMs(item, input.markers) : null;
340
- if (tMs === null || tMs < 0) {
341
- if (tMs === null) observerNavGapUnavailable = true;
342
- else observerPreArmUnavailable = true;
303
+ if (tMs === null) {
304
+ observerNavGapUnavailable = true;
343
305
  layoutShifts.push({
344
306
  tMs: null,
345
307
  value,
@@ -349,6 +311,10 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
349
311
  });
350
312
  continue;
351
313
  }
314
+ if (tMs < 0) {
315
+ preRecordingObserverEntriesExcluded++;
316
+ continue;
317
+ }
352
318
  const explicit = rectFactsFromObserverSources(item);
353
319
  const inferred = explicit.length ? undefined : inferredRectFacts(tMs, frameElementRecords);
354
320
  layoutShifts.push({
@@ -367,22 +333,15 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
367
333
  losses.push({
368
334
  kind: 'orphaned-finalized',
369
335
  message: 'recorder was finalized best-effort from artifacts already flushed to disk',
370
- affectedCounts: ['dropped-frames', 'long-task-records', 'layout-shift-records'],
336
+ affectedCounts: ['long-task-records', 'layout-shift-records'],
371
337
  });
372
338
  }
373
339
  const incomplete = new Set<JankCount>(losses.flatMap((loss) => loss.affectedCounts));
374
- if (missingFrameSampleCount > 0 || cadenceMs === null) incomplete.add('dropped-frames');
375
340
  const traceAligned = longTasks.some((task) => task.source === 'trace' && task.timingDomain === 'recorder-performance');
376
341
  const traceRelative = longTasks.some((task) => task.source === 'trace' && task.timingDomain === 'trace-relative-first-event');
377
342
  const frameTimestampUncertainty: '±frame' | 'unavailable' = frames.length ? '±frame' : 'unavailable';
378
- // Attribute each unavailable-timing cause factually. A navigation gap and a pre-arm
379
- // buffered entry both route to 'unavailable', but only the former involves a navigation;
380
- // asserting navigation for a pre-arm entry would be false provenance.
381
- const observerUnavailableCauses: string[] = [];
382
- if (observerNavGapUnavailable) observerUnavailableCauses.push('some occurred after a navigation gap with no synchronized recorder-relative baseline');
383
- if (observerPreArmUnavailable) observerUnavailableCauses.push('some were buffered from before the recorder arm baseline, with a document timestamp that predates it');
384
- const observerTiming = observerUnavailableCauses.length
385
- ? `Observer entries with unavailable timing (${observerUnavailableCauses.join('; ')}) have no recorder-relative baseline; their timing, dropped-frame overlap, and frame-diff attribution are unavailable.`
343
+ const observerTiming = observerNavGapUnavailable
344
+ ? 'Observer entries after a navigation gap with no synchronized recorder-relative baseline have unavailable timing and frame-diff attribution.'
386
345
  : 'Observer and screencast timestamps are recorder-relative performance.now() milliseconds.';
387
346
  const timingNote = traceRelative
388
347
  ? `${observerTiming} Frame-derived intervals have ±frame uncertainty. Trace timestamps are relative to the first trace event because no explicit trace/performance baseline marker was retained.`
@@ -394,33 +353,20 @@ export function analyzeMotionJank(input: { rects: readonly unknown[]; events: re
394
353
 
395
354
  return {
396
355
  frameCount: frames.length,
397
- cadenceMs,
398
356
  missingFrameSampleCount,
399
- droppedFrames,
400
- droppedFrameCount: droppedFrames.reduce((count, item) => count + item.estimatedDroppedFrames, 0),
401
- droppedFramesIncomplete: incomplete.has('dropped-frames'),
402
357
  longTasks,
403
358
  longTasksIncomplete: incomplete.has('long-task-records'),
404
359
  layoutShifts,
405
360
  layoutShiftsIncomplete: incomplete.has('layout-shift-records'),
361
+ preRecordingObserverEntriesExcluded,
406
362
  artifactLoss: losses,
407
363
  frameTimestampUncertainty,
408
364
  timingNote,
409
365
  };
410
366
  }
411
367
 
412
- function longTask(source: 'observer' | 'trace', timingDomain: LongTaskTimingDomain, startMs: number, durationMs: number, droppedFrames: readonly DroppedFrameFact[]): LongTaskFact {
413
- const endMs = startMs + durationMs;
414
- return {
415
- source,
416
- timingDomain,
417
- startMs,
418
- durationMs,
419
- endMs,
420
- overlapsDroppedFrames: timingDomain === 'recorder-performance'
421
- ? droppedFrames.filter((drop) => startMs < drop.endMs && endMs > drop.startMs).map((drop) => drop.afterFrame)
422
- : null,
423
- };
368
+ function longTask(source: 'observer' | 'trace', timingDomain: LongTaskTimingDomain, startMs: number, durationMs: number): LongTaskFact {
369
+ return { source, timingDomain, startMs, durationMs, endMs: startMs + durationMs };
424
370
  }
425
371
 
426
372
  export function readMotionJank(ref: RecRef): { analysis: MotionJankAnalysis; meta: Record<string, unknown> } {
@@ -430,7 +376,7 @@ export function readMotionJank(ref: RecRef): { analysis: MotionJankAnalysis; met
430
376
  // completed recording, not one awaiting `--stop` (which only applies to an
431
377
  // active composed lifecycle recording) — its retained event/trace records
432
378
  // remain readable, so long-task and layout-shift facts stay available;
433
- // only frame-derived counts fall back to "incomplete" via cadenceMs===null.
379
+ // while frame-derived timing remains unavailable without rect timestamps.
434
380
  // Mirrors the same exception already made in response.ts's loadResponseTimeline.
435
381
  const readablePartial = state === 'partial' && meta.reason === 'no_frames';
436
382
  if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
@@ -66,8 +66,8 @@ interface Component {
66
66
 
67
67
  /** Per-component facts computed in single shared passes over the label map. */
68
68
  interface ComponentStats {
69
- /** Best element attribution by summed pixel overlap across rect records. */
70
- attribution: { element: MotionElement; overlap: number } | null;
69
+ /** Best element attribution by changed-pixel coverage relative to its recorded area. */
70
+ attribution: { element: MotionElement; overlap: number; area: number } | null;
71
71
  /** Pair-ordered centroids of this component's changed pixels, one per active sample. */
72
72
  activeCentroids: Array<{ pair: number; cx: number; cy: number }>;
73
73
  }
@@ -288,7 +288,7 @@ function computeComponentStats(
288
288
  }
289
289
  }
290
290
 
291
- const candidates: Array<Map<string, { element: MotionElement; overlap: number }>> = components.map(() => new Map());
291
+ const candidates: Array<Map<string, { element: MotionElement; overlap: number; area: number }>> = components.map(() => new Map());
292
292
  for (const record of recordsByFile.values()) {
293
293
  for (const element of record.elements ?? []) {
294
294
  if (![element.x, element.y, element.width, element.height].every((value) => typeof value === 'number' && Number.isFinite(value))) continue;
@@ -299,22 +299,31 @@ function computeComponentStats(
299
299
  const x1 = Math.min(width, Math.ceil(transformed.x + transformed.width));
300
300
  const y0 = Math.max(0, Math.floor(transformed.y));
301
301
  const y1 = Math.min(height, Math.ceil(transformed.y + transformed.height));
302
+ const area = (x1 - x0) * (y1 - y0);
303
+ if (area <= 0) continue;
304
+ const overlaps = new Map<number, number>();
302
305
  for (let py = y0; py < y1; py++) {
303
306
  const rowBase = py * width;
304
307
  for (let px = x0; px < x1; px++) {
305
308
  const label = labels[rowBase + px];
306
- if (label < 0) continue;
307
- const map = candidates[label];
308
- const existing = map.get(key);
309
- if (existing) existing.overlap++;
310
- else map.set(key, { element, overlap: 1 });
309
+ if (label >= 0) overlaps.set(label, (overlaps.get(label) ?? 0) + 1);
310
+ }
311
+ }
312
+ for (const [label, overlap] of overlaps) {
313
+ const map = candidates[label];
314
+ const existing = map.get(key);
315
+ if (existing) {
316
+ existing.overlap += overlap;
317
+ existing.area += area;
318
+ } else {
319
+ map.set(key, { element, overlap, area });
311
320
  }
312
321
  }
313
322
  }
314
323
  }
315
324
 
316
325
  return components.map((_, index) => ({
317
- attribution: [...candidates[index].values()].sort((a, b) => b.overlap - a.overlap)[0] ?? null,
326
+ attribution: [...candidates[index].values()].sort((a, b) => b.overlap / b.area - a.overlap / a.area || b.overlap - a.overlap)[0] ?? null,
318
327
  activeCentroids: activeCentroids[index],
319
328
  }));
320
329
  }