@wafertools/wafermap 0.30.2 → 0.30.4

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 (88) hide show
  1. package/AGENTS.md +16 -5
  2. package/CHANGELOG.md +432 -0
  3. package/README.md +6 -3
  4. package/dist/packages/canvas-adapter/chartPopulation.d.ts +48 -0
  5. package/dist/packages/canvas-adapter/chartPopulation.js +1 -0
  6. package/dist/packages/canvas-adapter/charts/barPanel.d.ts +4 -1
  7. package/dist/packages/canvas-adapter/charts/barPanel.js +1 -1
  8. package/dist/packages/canvas-adapter/charts/boxplot.d.ts +4 -1
  9. package/dist/packages/canvas-adapter/charts/boxplot.js +1 -1
  10. package/dist/packages/canvas-adapter/charts/capability.js +1 -1
  11. package/dist/packages/canvas-adapter/charts/chartShell.d.ts +82 -8
  12. package/dist/packages/canvas-adapter/charts/chartShell.js +1 -1
  13. package/dist/packages/canvas-adapter/charts/correlation.js +2 -2
  14. package/dist/packages/canvas-adapter/charts/groupedBarPlot.js +1 -1
  15. package/dist/packages/canvas-adapter/charts/histogram.js +1 -1
  16. package/dist/packages/canvas-adapter/charts/regionYieldDiagram.js +1 -1
  17. package/dist/packages/canvas-adapter/charts/scatter.d.ts +5 -1
  18. package/dist/packages/canvas-adapter/charts/scatter.js +1 -1
  19. package/dist/packages/canvas-adapter/charts/sweep.d.ts +31 -0
  20. package/dist/packages/canvas-adapter/charts/sweep.js +1 -0
  21. package/dist/packages/canvas-adapter/charts/testPassRate.js +1 -1
  22. package/dist/packages/canvas-adapter/charts/trend.d.ts +3 -1
  23. package/dist/packages/canvas-adapter/charts/trend.js +1 -1
  24. package/dist/packages/canvas-adapter/dieList.js +2 -2
  25. package/dist/packages/canvas-adapter/drilldown.d.ts +23 -0
  26. package/dist/packages/canvas-adapter/drilldown.js +1 -0
  27. package/dist/packages/canvas-adapter/icons.js +1 -1
  28. package/dist/packages/canvas-adapter/insightsTab.d.ts +65 -3
  29. package/dist/packages/canvas-adapter/insightsTab.js +1 -1
  30. package/dist/packages/canvas-adapter/maplessSummary.js +1 -1
  31. package/dist/packages/canvas-adapter/renderWaferGallery.js +1 -1
  32. package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
  33. package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
  34. package/dist/packages/canvas-adapter/toCanvas.js +1 -1
  35. package/dist/packages/canvas-adapter/toolbar.d.ts +30 -2
  36. package/dist/packages/canvas-adapter/toolbar.js +2 -2
  37. package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
  38. package/dist/packages/canvas-adapter/userGuideHtml.js +162 -37
  39. package/dist/packages/canvas-adapter/version.d.ts +2 -2
  40. package/dist/packages/canvas-adapter/version.js +1 -1
  41. package/dist/packages/canvas-adapter/warnings.js +1 -1
  42. package/dist/packages/core/dies.d.ts +1 -1
  43. package/dist/packages/renderer/axisTicks.d.ts +32 -0
  44. package/dist/packages/renderer/axisTicks.js +1 -0
  45. package/dist/packages/renderer/binColors.js +1 -1
  46. package/dist/packages/renderer/buildView.d.ts +11 -37
  47. package/dist/packages/renderer/buildView.js +1 -1
  48. package/dist/packages/renderer/buildWaferMap.d.ts +97 -12
  49. package/dist/packages/renderer/buildWaferMap.js +1 -1
  50. package/dist/packages/renderer/deprecate.d.ts +13 -5
  51. package/dist/packages/renderer/deprecate.js +1 -1
  52. package/dist/packages/renderer/derivedTests/apply.d.ts +85 -0
  53. package/dist/packages/renderer/derivedTests/apply.js +1 -0
  54. package/dist/packages/renderer/derivedTests/evaluate.d.ts +48 -0
  55. package/dist/packages/renderer/derivedTests/evaluate.js +1 -0
  56. package/dist/packages/renderer/derivedTests/parser.d.ts +148 -0
  57. package/dist/packages/renderer/derivedTests/parser.js +2 -0
  58. package/dist/packages/renderer/fmt.d.ts +24 -2
  59. package/dist/packages/renderer/fmt.js +1 -1
  60. package/dist/packages/renderer/index.d.ts +1 -0
  61. package/dist/packages/renderer/index.js +1 -1
  62. package/dist/packages/renderer/spec.d.ts +66 -0
  63. package/dist/packages/renderer/spec.js +1 -0
  64. package/dist/packages/renderer/testLabel.d.ts +154 -0
  65. package/dist/packages/renderer/testLabel.js +1 -0
  66. package/dist/packages/stats/analyzeWaferMap.js +1 -1
  67. package/dist/packages/stats/capability.d.ts +8 -2
  68. package/dist/packages/stats/capability.js +1 -1
  69. package/dist/packages/stats/correlation.d.ts +4 -1
  70. package/dist/packages/stats/correlation.js +1 -1
  71. package/dist/packages/stats/deprecated.d.ts +4 -1
  72. package/dist/packages/stats/deprecated.js +1 -1
  73. package/dist/packages/stats/index.d.ts +2 -2
  74. package/dist/packages/stats/index.js +1 -1
  75. package/dist/packages/stats/mergeTestDefs.d.ts +1 -1
  76. package/dist/packages/stats/mergeTestDefs.js +1 -1
  77. package/dist/packages/stats/renderFindingsReport.js +8 -28
  78. package/dist/packages/stats/renderSummaryReport.js +19 -28
  79. package/dist/packages/stats/reportHtml.d.ts +27 -0
  80. package/dist/packages/stats/reportHtml.js +35 -14
  81. package/dist/packages/stats/sweep.d.ts +186 -0
  82. package/dist/packages/stats/sweep.js +1 -0
  83. package/dist/packages/stats/sweepXFromName.d.ts +27 -0
  84. package/dist/packages/stats/sweepXFromName.js +1 -0
  85. package/dist/packages/stats/testPassRate.js +1 -1
  86. package/dist/packages/stats/types.d.ts +37 -4
  87. package/llms.txt +3 -2
  88. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -122,10 +122,21 @@ reporting, not a pattern to build on.
122
122
  - **The analysis surfaces are already built — do not reimplement them.** Pass
123
123
  `statsSummary` to `renderWaferMap` and it mounts the Summary panel; pass
124
124
  `insights: { enabled: true }` and it mounts the chart suite (yield, bin pareto,
125
- boxplot, histogram, correlation, scatter, capability). `renderWaferGallery` takes the
126
- same option across a whole lot. Supply or replace the analysis later with
127
- `setStatsSummary()`. Hand-building those charts is what the deprecated chart-data
128
- builders were for, and they go in 0.31.0.
125
+ boxplot, histogram, correlation, scatter, capability, and one card per
126
+ `insights.sweeps` entry). `renderWaferGallery` takes the same option across a whole
127
+ lot. Supply or replace the analysis later with `setStatsSummary()`. Hand-building
128
+ those charts is what the deprecated chart-data builders were for, and they go in
129
+ 0.31.0. Charting a selection or one wafer (right-click → histogram, capability,
130
+ sweeps) is built in too, with no wiring.
131
+ - **A value computed from other tests is a derived test, not a host-side column.** Pass
132
+ `derivedTests` (a `TestDef` plus an `expression`, e.g. `'abs(t[1020] - t[1010])'`) to
133
+ `buildWaferMap`; it then behaves as a measured test everywhere, marked `†` as not
134
+ measured. Computing it in the host and injecting it into `testValues` loses that
135
+ mark, the missing-input rule (absent, never 0) and the collision check. A boolean
136
+ expression must be declared `testType: 'F'`.
137
+ - **A sweep's x values are data, not guesses.** Give `xValues`, or `xFromName` (a
138
+ `{x}` placeholder pattern, not a regex) when the swept value is only in the test
139
+ name. Never derive x from test numbers: they are identifiers, not a scale.
129
140
  - **Click-to-highlight is wired, not hand-rolled.** `onSelect` reports what the user
130
141
  picked; `setSelection(dies)` / `clearSelection()` drive it from your own UI — for
131
142
  example from a finding, whose `dieKeys` match `getDieKey(die)` exactly.
@@ -240,7 +251,7 @@ compatibility shims for them.
240
251
  - [API reference](https://wafertools.github.io/wafermap/api/) — every type, option and return value
241
252
  - [Developer guide](https://wafertools.github.io/wafermap/guide/) — worked walkthroughs
242
253
  - [Troubleshooting](https://wafertools.github.io/wafermap/troubleshooting/)
243
- - [Examples](https://wafertools.github.io/wafermap/examples/) — 20 runnable pages, also
254
+ - [Examples](https://wafertools.github.io/wafermap/examples/) — 22 runnable pages, also
244
255
  [downloadable](https://wafertools.github.io/wafermap/wafermap-examples.zip) to run offline
245
256
 
246
257
  When a rule here and the API reference disagree, the API reference wins — tell the
package/CHANGELOG.md CHANGED
@@ -22,6 +22,438 @@ under `### Breaking`.
22
22
 
23
23
  ---
24
24
 
25
+ ## [Unreleased]
26
+
27
+ ## [0.30.4] — 2026-09-25
28
+
29
+ ### Added
30
+
31
+ - **`input-values-outside-stdf` warning** from `buildWaferMap`: bins, coordinates, test
32
+ numbers or site numbers outside the STDF V4 ranges, test values that are not finite, and a
33
+ `waferConfig.orientation` other than 0, 90, 180 or 270. The values are used as given; a
34
+ future release treats them as missing.
35
+ - **`DieResult.supersedes`** (`'partId' | 'position'`): the tester marked the record as
36
+ replacing an earlier one. Such a record always wins, whatever `retestPolicy` says.
37
+ - **Retests of dies with no position are matched by part ID** within a wafer, resolved by
38
+ `retestPolicy` and reported with an `info` warning, `retests-by-part-id`. Blank part IDs
39
+ never match, and part IDs are not used on a wafer where one value covers more than 20% of
40
+ the unpositioned records.
41
+ - **`TestDef.specLow` / `TestDef.specHigh`**: a test's spec limits, separate from its test
42
+ limits (`limitLow`/`limitHigh`), as STDF records them. Process capability uses the spec
43
+ limits when both are given and the test limits otherwise, and reports which as
44
+ `TestCapability.limitBasis`. Wafers that disagree on a test's spec limits raise a
45
+ `specLimits` test-definition conflict.
46
+ - **Sweeps tab notice for sweeps that name no test in the data**, and
47
+ **`InsightsOptions.onRemoveSweeps`**, which adds a Remove button to it. The sweep's own card
48
+ says the same in place of an empty chart.
49
+ - **`TestDef.limitLowInclusive` / `TestDef.limitHighInclusive`**: whether a value exactly
50
+ equal to a test limit passes (default `true`), as STDF and ATDF state per test. Every
51
+ pass/fail judgement, yield and out-of-limit marker honours them. Wafers that disagree on
52
+ them raise a `limits` test-definition conflict.
53
+
54
+ ### Changed
55
+
56
+ - **`DieResult.partId` and `Die.partId` accept text** (`number | string`), as STDF defines
57
+ part IDs; a number is still accepted.
58
+ - **Scatter chart legend:** hard bin 0 has its own entry and colour, and dies without a bin
59
+ are listed last as "No bin data".
60
+ - **A `NaN` bin is no bin**: it is neither a bin number nor a fail, and is counted in the new
61
+ warning.
62
+ - **One spec-limit judgement** (`classifySpec`) serves every surface, including the per-test
63
+ spec yield, lot-stack fail filtering, regional spec-fail findings and the Summary panel.
64
+ A value that is not a finite number has no verdict everywhere, rather than counting as a
65
+ pass on some surfaces.
66
+ - **Bin colours** for a bin that is not a whole number are taken from the whole bin below it.
67
+ - **Test limits and spec limits are labelled apart.** `limitLow`/`limitHigh` are test limits,
68
+ labelled "Lo limit"/"Hi limit" on charts; LSL/USL appear only where a test's spec limits
69
+ are shown. The pass/fail option is "Limit pass/fail", the Summary panel's yield column is
70
+ "Limit yield", and the capability chart, tooltip and report name the limits each test was
71
+ judged against.
72
+
73
+ ## [0.30.3] — 2026-09-23
74
+
75
+ ### Added
76
+
77
+ - **Sweep x values from test names, an x unit, and a log x axis.** For a
78
+ program that records the swept quantity only in the test text — a
79
+ resistance CDF named `Normalized_LRS= LRS_STATS_12K / …`, one test per
80
+ threshold:
81
+ - `SweepSeriesSpec.xFromName` reads each test's x from its name with a
82
+ placeholder pattern, `"LRS_STATS_{x}"`: `{x}` reads a number with its SI
83
+ prefix (`12K` → 12,000; case-sensitive, `K` accepted as kilo; a letter is a
84
+ prefix only alone or before a unit, so `12Kangaroos` reads 12; in an all-capitals
85
+ name the unit's case is gone, so `5NS` is 5 ns and `2MV` — milli or mega? — is
86
+ reported, not guessed), `*` matches
87
+ anything, literal text matches in any case, anywhere in the name. Not a
88
+ regular expression: a sweeps file is shared, and JavaScript cannot
89
+ interrupt a runaway regex, whereas this matcher's cost is bounded whatever
90
+ the pattern (`stats/sweepXFromName.ts`). Each x stays attached to its own
91
+ test, so a missing test loses one point instead of shifting the rest.
92
+ - `SweepSpec.xUnit` prints the x axis, crossing and widths SI-prefixed
93
+ (`47.3 kΩ`), trailing zeros dropped (`2 kΩ`, not `2.00 kΩ`).
94
+ - `SweepSpec.xScale: 'log'` places x on a log axis; the crossing is
95
+ interpolated along log x and a width is reported as a ratio with both ends
96
+ (`×2.49 (15.9 kΩ → 39.7 kΩ)`), via new `SweepSeparation.ratio`/`from`/`to`.
97
+ `SweepData.xScale` is the axis actually drawn: a non-positive x keeps it
98
+ linear, with a warning.
99
+ - Reported, never guessed, and then not measured: a name the pattern does not
100
+ fit, a series giving both `xValues` and `xFromName`, and — for every x
101
+ source — a series that revisits an x value, which the crossing previously
102
+ mis-paired silently.
103
+
104
+ - **Drilldown: chart a selection or a wafer.** Right-click a population for a
105
+ menu of charts drawn from just those dies, opened in the same modal as an
106
+ expanded Insights card:
107
+ - **Populations:** the dies selected on a map (a single map or any gallery
108
+ card); a whole wafer, from empty map space with nothing selected, from a
109
+ gallery card anywhere outside its map, or from one wafer's bar, box or point
110
+ in *Yield by wafer*, *Test value distribution* or *Wafer-to-wafer trend* in
111
+ Insights (their tooltips say "right-click to chart this wafer"). The Menu
112
+ key, Shift+F10 and a new map toolbar **Chart** button (the selection if
113
+ there is one, else the wafer) reach the same menu without a mouse.
114
+ - **Charts:** value histogram (opening on the test the map shows), process
115
+ capability, and the sweeps defined in `insights.sweeps` — the gallery now
116
+ passes those to each card, and a single map offers them whether or not
117
+ `insights.enabled` is set.
118
+ - **Population stated on every chart**, in the modal title and on the card
119
+ ("12 dies selected on W03", "10 of 12 … (partial and edge-excluded dies left
120
+ out)"). Capability below 30 dies adds that each Ppk is a rough estimate.
121
+ Charts are snapshots: changing the selection afterwards does not change them.
122
+ The wafer is named by the item's `label`, else its `metadata.waferId` — never a
123
+ positional "Wafer 3 (no ID)" — on the map, the gallery and Insights alike.
124
+ - **The library decides what is offered:** a chart that cannot be drawn stays
125
+ listed, greyed, with the reason (no values for its tests; fewer than two
126
+ dies for capability; a lot-stack map, whose dies are per-position aggregates
127
+ rather than measured dies). Right-clicking an unselected die selects it
128
+ first. A map with no parametric tests and no sweeps leaves right-click to
129
+ the browser or host, and the map canvas owns right-click on itself: one it
130
+ declines (the bin legend) never reaches a gallery card or host handler.
131
+ - Documented in api.md §5.12, the user guide §4.4 (with screenshots from
132
+ `capture-definitions.mjs`) and the developer guide.
133
+ - Built on one source/target mechanism (`canvas-adapter/chartPopulation.ts`
134
+ builds the populations, `drilldown.ts` the menu and charts, loaded on first
135
+ use and pinned by `tests/bundle-size.test.mjs`), so a further population or
136
+ chart is one more of either rather than a menu per pairing.
137
+
138
+ - **Derived tests** — `WaferMapInput.derivedTests`: tests computed from other tests
139
+ on the same die, rather than measured. Each entry is a `TestDef` plus an
140
+ `expression`, and from the build onwards it is an ordinary test — it appears in
141
+ `result.testDefs`, in the value plot modes, the colorbar, tooltips,
142
+ `analyzeWaferMap`, Insights and the report, with no other change required.
143
+
144
+ ```ts
145
+ derivedTests: [
146
+ { testNumber: 900001, name: 'Leakage Shift', unit: 'uA',
147
+ expression: 'abs(t[1020] - t[1010])', limitHigh: 5 },
148
+ { testNumber: 900002, name: 'Sweep All Pass', testType: 'F',
149
+ expression: 'all(testPass[1010..1025])' },
150
+ ]
151
+ ```
152
+
153
+ Expressions read `t[n]` (measured value), `testPass[n]` (the tester's recorded
154
+ verdict), `specPass[n]` (spec-limit judgement) and `diePass()` (the die's bin
155
+ verdict) — the same `'test'`/`'spec'` distinction `passFailDisplay` already
156
+ draws, because they are different questions and a single accessor would have
157
+ silently meant "recorded verdict" for parametric data that has none. A range
158
+ (`t[1010..1015]`) yields a set that must be reduced by `mean`, `sum`, `min`,
159
+ `max`, `all`, `any`, `none`, `countTrue`, `countFalse` or `countKnown`; there is
160
+ no vector arithmetic, which is what stops the grammar becoming an array
161
+ language. Operators `+ - * / % ^`, comparisons, `and`/`or`/`not`, and
162
+ `if(cond, a, b)`.
163
+
164
+ Parsed to a typed tree at build time and walked per die — **no `eval`, no
165
+ `Function`, no third-party expression engine, and no way to name a host
166
+ object** — so a chart spec can be shared between teams as plain JSON. This is
167
+ why the root bundle grows ~5 KB gzip.
168
+
169
+ Evaluated on raw probe records **before** lot stacking and retest resolution, so
170
+ every derived value comes from one real touchdown: deriving after a stack would
171
+ subtract one aggregate from another, and deriving after retest collapse could mix
172
+ a value from one touchdown with a verdict from another.
173
+
174
+ A boolean expression is a verdict — declare `testType: 'F'` and the result lands
175
+ in `die.testPass`, never as a 1/0 in `testValues` where it would enter the
176
+ correlation matrix and the Cpk table. An unknown input yields an **absent** value
177
+ (no-data grey), never `0` and never `false`.
178
+
179
+ Everything statically knowable is checked at build and reported as a
180
+ `derived-test-invalid` warning naming the position — a parse or type error, a
181
+ `testNumber` colliding with measured data, a `testType` that disagrees with the
182
+ expression, `t[n]` on a functional test, `specPass[n]` on a test with no limits.
183
+ A rejected derived test is **dropped**, never half-applied.
184
+
185
+ - **Parametric sweeps** — `insights.sweeps` on `renderWaferMap`/`renderWaferGallery`:
186
+ an ordered run of tests read as a response curve rather than as independent
187
+ tests, with the crossing point of the first two series and the horizontal
188
+ separation at named levels measured and stated. `xValues` supplies the real
189
+ swept quantity so the crossing is reported in physical units; without it the
190
+ axis is the ordinal position, because test numbers are identifiers and are not
191
+ guaranteed to be evenly spaced. Each line is the population median with a
192
+ p10–p90 band. A multiple crossing says so; a level neither curve reaches reads
193
+ "not measurable" naming the series, never `0`.
194
+ - **Test ranges in a sweep's `tests`** — an entry may be `"1010..1030"`, the exact
195
+ syntax of a derived-test expression's `t[1010..1030]` and parsed by the same code
196
+ (`parseTestReference`), so the same text always names the same tests. A range
197
+ expands to the declared tests inside it, ascending — a program numbered in steps
198
+ of 2 needs no step syntax. `xValues` is checked against the expanded list: when
199
+ a test is missing from a range, the footer lists what the range matched and the
200
+ card is drawn in test order with the crossing and widths **not measured**
201
+ (`SweepData.notMeasured`), rather than sliding every later x value onto the
202
+ wrong test. The same now applies when only some series carry `xValues`, which
203
+ before put physical and ordinal x positions on one axis and reported an ordinal
204
+ crossing under the physical x label. A range resolves by filtering the declared
205
+ tests rather than counting through every integer, so `0..2000000000` in a shared
206
+ template is not a two-billion-step loop — in expressions too.
207
+ - **Nested derived tests** — a derived test may read another. Compilation
208
+ topologically sorts by what each expression reads, so declaration order does
209
+ not matter; cycles, self-references and dependents of a rejected test are
210
+ dropped with the cause named.
211
+ - New example: **Derived tests and sweeps** (`docs/examples/derived-tests.html`).
212
+ - **Sweeps have their own Insights sub-tab**, shown only when `insights.sweeps`
213
+ defines at least one; `InsightsView` gains `'sweeps'` for `defaultView`, which
214
+ falls back to `'overview'` when there are none. Not an option and not a count
215
+ threshold: a threshold would move a sweep between tabs the day a colleague
216
+ added another, and an option would be a layout flag with one right answer.
217
+ They never belonged in Distributions either — that view is driven by one
218
+ selected test (capability → boxplot → histogram → trend), while a sweep draws
219
+ many tests as one curve and ignores the selection.
220
+ - New example: **Parametric sweeps** (`docs/examples/sweeps.html`) — four
221
+ characterisation sweeps in the Sweeps tab, each a different use of the
222
+ crossing and the width: the temperature-inversion voltage (Fmax vs VDD at
223
+ 25/125 °C, with the iso-frequency VDD gap at two speeds), DIBL from an NMOS
224
+ transfer curve at two drain biases, the retention bake at which the read
225
+ window closes (uneven read times, which is what `xValues` is for), and an
226
+ output driver's maximum drive current (VOH vs VOL under load). The transfer
227
+ curve is swept over **derived** `log10(t[n])` tests — a sweep reads any test,
228
+ which is how a log-scale curve is drawn without a log option.
229
+ - **Derived tests are marked on the sweep card**: `SweepPoint` carries `derived`
230
+ and `expression`, ordinal tick labels keep the `†` in front of a truncated name, the
231
+ tooltip marks each series row and adds each expression, and the footer shows
232
+ the key.
233
+ - **Sweep card text.** The crossing line lower-cased the y label ("Cell Vt"
234
+ became "cell vt", "Fmax" became "fmax") — the width line had already been
235
+ fixed for the same reason ("dBm" → "dbm"); widths printed in engineering
236
+ notation (`32.7E-3`); and an x label carrying its own unit doubled the
237
+ brackets (`(VDD (V))`). X values — ticks, crossing and widths — now share one
238
+ plain-decimal rule, and a width reads "0.0327 on the VDD (V) axis".
239
+ - The derived-tests example opened on a Switching Window measured at the step
240
+ where its two curves cross, so the gap sat around zero against `limitLow: 0`
241
+ and nearly every die carried a ▽. It is now measured at a fixed read step
242
+ with a 0.33 V margin, so only an edge band fails.
243
+ - **A derived test is now identifiable from `result.testDefs` alone.** `derived`
244
+ was declared only on the `DerivedTestDef` *input* type, while `result.testDefs`
245
+ is a homogeneous `TestDef[]` — so every display surface needed a cast to ask
246
+ whether a value was measured or derived, and `expression`/`constants` were
247
+ stripped off the admitted def entirely, leaving nothing to say where a number
248
+ came from. All three now travel on `TestDef` as `readonly` fields set by
249
+ `buildWaferMap`. This is the prerequisite for marking derived tests in the UI:
250
+ a display that cannot ask the question cannot mark it, and an engineer reading a
251
+ Cpk table should never be left to assume a number came off the tester. The
252
+ alternative — hosts holding their own `derivedTests` input beside the result and
253
+ joining by test number — is a second source of truth that can disagree with the
254
+ def actually admitted, which carries the resolved `testType`.
255
+ - **`testLabel` / `isDerivedTest` / `derivedTestSource` / `hasDerivedTests`**
256
+ (`renderer/testLabel.ts`, internal) — one definition of how a test is named and
257
+ how a derived one is distinguished. The "name, or *Test N* when it has none"
258
+ fallback existed in six places (`stats/sweep.ts`, `capability.ts`, `testPassRate.ts`,
259
+ `analyzeWaferMap.ts`, `charts/chartShell.ts`), all now routed through it. Around
260
+ sixteen surfaces display a test name; a marker added per surface would have been
261
+ that rule twenty-odd times over, which is how two of them end up disagreeing.
262
+ - **Derived tests are marked in the process capability panel.** A `†` precedes
263
+ the test name, the legend gains the key "† Derived, not measured", and the
264
+ tooltip says the same in words and shows the expression the value was
265
+ computed from. A dagger, not `ƒ`: in this domain `f` reads as femto,
266
+ and a `ƒ` beside `fA`/`fF` units is a real misread.
267
+ `TestCapability`/`CapabilityDatum` gain `derived` and `expression`.
268
+ - **…and everywhere else a test is named, always in front of the name.** In a
269
+ list the marks then form a column down the left edge, so a derived test is found
270
+ at a glance, and truncating a long name can never cut the mark off; a list
271
+ holding a derived test pads its measured names by the mark's width so names stay
272
+ aligned, and a list without one is unchanged. The map title and colorbar
273
+ (`† Leak Shift (nA)`)
274
+ with the key on its own line beneath, drawn via a new optional
275
+ `MapTitleParts.note`; the map tooltip, which adds the key and the expression;
276
+ finding labels and sentences, marked at the source so no reader of a finding
277
+ can show it unmarked, with `StatsFinding.variable.derived`/`expression` as the
278
+ structured form; `stats.functionalYield[].label` (plus `derived`/`expression`);
279
+ the Summary panel's parametric and functional tables and its findings list,
280
+ each with a key line naming every expression; and both HTML reports, whose key
281
+ lists the expressions because a printed report has no hover. CSV exports carry
282
+ plain names plus a trailing **Derived from** column, only when a row is
283
+ derived — a CSV has nowhere to put a key. The key is a single short string,
284
+ *Derived, not measured*, so it fits the corner of a map and cannot drift
285
+ between surfaces. Every surface goes through `renderer/testLabel.ts`, which
286
+ also replaced two more hand-written *Test N* fallbacks in `buildView.ts` and
287
+ one in the gallery's stacked cards, whose synthetic def now keeps the flag.
288
+ The finding tooltip now has one rule, `formatFindingTooltip`, which the
289
+ Summary panel shares with both reports instead of using `summary` directly.
290
+ The places a test is *chosen* are marked too: the plot-mode test menu (a slot
291
+ in front of each name, reserved only when a derived test is listed, and a key
292
+ line at the foot), the Insights test pickers (`testOptionLabels`), the
293
+ correlation matrix's axis labels and tooltip (key on its hint line; its CSV
294
+ gains per-side *derived from* columns) and the die list's column headers (key
295
+ line above the table; the CSV header states the expression). The last two
296
+ hand-written *Test N* fallbacks, in the plot-mode menu and the die list, now go
297
+ through `testLabel`.
298
+ - **`DERIVED_MARK` and `DERIVED_KEY` are exported** (root and `/renderer`), so a
299
+ host listing tests in its own UI marks derived tests exactly as the library does
300
+ instead of keeping its own copy of the glyph and the words. tsmap's test selector
301
+ is the first use.
302
+ - Getting there turned up two places that dropped the flag. **`mergeTestDefs`**,
303
+ which builds a gallery's single test list, rebuilt each def field by field and
304
+ never named `derived`, so every cross-wafer panel would have shown a derived
305
+ test unmarked while the single-wafer view marked it. It now carries `derived`
306
+ and `expression`, marking a test derived when **any** wafer says so: marking
307
+ a measured test is an oddity someone queries, while leaving a derived one
308
+ unmarked goes unnoticed. The Summary panel's per-test table narrowed defs the
309
+ same way and now keeps both fields. It also held a seventh copy of the
310
+ *Test N* fallback, now routed through `testLabel`.
311
+
312
+ ### Changed
313
+
314
+ - **Docs and examples brought up to date with this release.** The developer
315
+ guide's sweep section no longer says `tests` has no range syntax or that a
316
+ sweep has no log option; it covers ranges, `xFromName`, `xUnit`, `xScale` and
317
+ drilldown. The sweeps example adds a fifth sweep (an RRAM resistance
318
+ distribution read from test names on a log axis) and gives the output-driver
319
+ sweep an x unit; the derived-tests example's sweep uses ranges and a real swept
320
+ quantity (pulse amplitude in volts) instead of ordinal steps, and places the †
321
+ in front of the name as everywhere else. The README, `AGENTS.md` and
322
+ `llms.txt` now mention derived tests, sweeps and drilldown.
323
+ Those two examples' code cards, a four-across grid that cut every code
324
+ block off at about half its width in 12 px type, now show one example per
325
+ row — the code whole, in monospace at 13.5 px, beside notes at 14.5 px,
326
+ stacked on narrow screens — from one shared style in `demo.css`. `--mono`,
327
+ which every example's code referenced and nothing defined, is now set.
328
+ The derived-tests example is now in two labelled parts — derived tests are
329
+ *data* (`buildWaferMap`'s `derivedTests`; tsmap's test-definitions file), a
330
+ sweep is a *chart* (the renderer's `insights.sweeps`; tsmap's separate sweeps
331
+ file) — because a run of look-alike snippets read as one list. Its snippets
332
+ show the whole call each belongs to, it says the sweep is in Insights →
333
+ Sweeps, with a *Show the sweep* button that presses the gallery's own Insights
334
+ button, and it links to the sweeps example. The sweeps example opens with
335
+ where its entries go.
336
+
337
+ - **`scripts/check-bundle-size.mjs` attributes lazy chunks by what imports
338
+ them, not by file name.** Once drilldown and Insights shared the chart panels,
339
+ esbuild split the shared code into an anonymous `chunk-*.js`, which a file-name
340
+ test counted as core. That would have added ~5 KB of lazy code to the "always
341
+ downloaded" figure. The guide, Insights and drilldown now count as their own
342
+ static closure minus what the entry already loads. The Insights figure now
343
+ includes the chart code it shares with drilldown, because opening Insights
344
+ downloads it.
345
+ - **Axis ticks sit on round values, and are labelled to their spacing.** Tick
346
+ labels used to take their decimals from each value's own size, never from the
347
+ gap between ticks, so a 1–10 axis read "2.00 4.00 6.00 8.00" and a bandgap's
348
+ 1.195–1.205 V read "1.20" five times — a sloped curve labelled as a flat line.
349
+ And only the colorbar placed ticks on round numbers: the box plot, scatter,
350
+ trend and sweep divided the data range into equal fractions (2.54 / 2.04 /
351
+ 1.53 GHz), the histogram labelled its bucket edges, and its count axis carried
352
+ an inline copy of the rounding rule with different breakpoints. Every numeric
353
+ axis now goes through `renderer/axisTicks.ts` — one 1-2-5 rounding rule
354
+ (`niceStep`), round ticks inside the data range, and labels with exactly the
355
+ decimals the step needs (`stepDecimals`): "2 4 6 8", "1.196 1.198 1.200".
356
+ **Density comes from the space, not a tick count:** `fitTicks` takes the
357
+ finest round step whose labels fit — measured label widths plus an 8 px gap
358
+ on a horizontal axis, a line spacing on a vertical one. A fixed count, tried
359
+ first, rounded a 200 px Idsat box plot (0.863–1.56 mA) to 1.0 / 1.2 / 1.4 —
360
+ fewer ticks than the five it replaced; it now reads 0.9 / 1.0 / … / 1.5. The colorbar's exact min and max are data values rather than grid
361
+ values, so they get one decimal more, dropped when it is a zero — a 9.87
362
+ maximum no longer rounds to "10". Log-scale and integer-valued colorbars, die
363
+ labels and limit labels keep size-based formatting; they are not tick grids.
364
+ Every chart's tick labels change, so doc screenshots need recapturing.
365
+ - **The wafer and lot reports' findings table now uses plain bin terms**
366
+ ("hard bin 2", not "HBin 2"). Both HTML reports render one findings table,
367
+ `findingsTableHtml`; each report module had kept its own, and only the
368
+ findings-only report translated the bin terms.
369
+
370
+ ### Deprecated
371
+
372
+ - **`renderFindingsReportHtml`** — removed in 0.31.0. Use
373
+ `renderWaferReportHtml(result, summary)` or `renderLotReportHtml(results)`:
374
+ their Findings section is the same table, alongside the population and yield
375
+ it was found in. It was kept in 0.30.1 on the grounds that the Summary panel
376
+ used it, which had not been true since 0.20.0; nothing in wmap or tsmap calls
377
+ it. It ships deprecated in this release, so it goes in 0.31.0 with the 0.30.0
378
+ deprecations. `deprecated()` now takes a removal version per name, for a name
379
+ deprecated too late to go with the rest.
380
+
381
+ ### Fixed
382
+
383
+ - **A menu row's hint is no longer drawn under its own menu.** Menus sit in a
384
+ dedicated layer (`menuLayerFor`) that outranks everything else in its root,
385
+ and the shared tooltip was placed beside that layer — so the reason on a
386
+ greyed-out row (the drilldown menu's, the Overlays menu's) appeared behind the
387
+ menu it explained. `positionTooltip` now places the tooltip inside the
388
+ anchor's menu layer at `Z_ABOVE2`, above every menu there, still resolved per
389
+ root (a host `<dialog>`, a wmap modal).
390
+
391
+ - **The map's hover tooltip no longer jumps away from the die.** The shared
392
+ tooltip steps clear of its anchor when the anchor is under 40% of the window
393
+ tall — right for a toolbar button, which its tip must not cover, and wrong
394
+ for the map canvas, where the pointer is on the die the tip describes. Every
395
+ map that small (every gallery card, the smaller maps on a page such as the
396
+ geometry example, any map in a tall window) had its tip thrown below or above
397
+ the whole canvas, measured at up to 266 px from the pointer. The map now
398
+ passes `followPointer` to `positionTooltip` and the tip stays beside the
399
+ pointer; control tips keep stepping around their buttons.
400
+
401
+ - **An expanded chart grows into its modal, in the direction that suits it.**
402
+ Several cards kept their grid-card size inside the expand modal: the trend
403
+ and sweep plots stayed a few hundred pixels tall, ring and quadrant yield
404
+ kept a fixed-size circle, the correlation matrix kept small cells, process
405
+ capability stopped at ~160 px per test, and row charts (yield by wafer,
406
+ pareto, pass rate, value distribution) left most of a 700 px box empty. Each
407
+ card now declares how it grows (`setChartGrow`): **plots** fill width and
408
+ height in a wider box; **row charts** keep bars full-width, show every row the
409
+ box has room for, and the box opens sized to its rows (within the viewport);
410
+ **square** charts grow to the shorter side in a square box. Capability's
411
+ columns spread across the width while each box keeps its grid maximum width.
412
+ Growth past the grid size happens only while expanded, so the Insights grid
413
+ is unchanged; maximise/restore and drag-resize shrink back correctly.
414
+ - **Chart cards measure their chrome, not their stretch.**
415
+ `growCardToFitContent` took the card's overhead as `card height − body
416
+ height`, which counts any stretch as overhead and grows the card on every
417
+ redraw — why several panels opted out of stretching (`alignSelf: 'start'`),
418
+ which is what kept them small in the modal. It now sums the card's other
419
+ children, so a stretched card is harmless. Trend and sweep no longer ask for
420
+ the height they were given as their minimum, which would have stopped a
421
+ shrinking modal.
422
+ - **Chart tooltips wrap inside their background.** They were `nowrap` under a
423
+ 280 px cap, so a longer line — a sweep row with its p10–p90 and n, a derived
424
+ test's expression — ran out past the dark box. They now wrap within 320 px,
425
+ breaking an unbroken expression if they must. The sweep tooltip puts each
426
+ series' spread and n on a line of their own under its median.
427
+
428
+ - **A functional-test finding merged across adjacent regions reported no
429
+ difference, and hid the real one.** Since 0.20.3, when a functional pass-rate
430
+ signal spanned adjacent rings, sectors or quadrants, the merge pass that joins
431
+ them into one finding (`Rings 1–2`) had no functional branch and fell through
432
+ to the bin one. It counted dies whose hard bin equalled `variable.bin` — which
433
+ a functional finding does not have — so both sides counted 0, and the merged
434
+ finding read *"Rings 1–2 has HBin undefined occurrence 0.0 percentage points
435
+ lower than the rest of the map"*, with a meaningless p-value. It also
436
+ **replaced** the correct per-region findings it merged, so a real functional
437
+ signal spanning adjacent regions was shown as no difference at all. The merge
438
+ now recomputes the pass rate over the merged region exactly as the per-region
439
+ builder does (verdicts through `getTestPassStatus`, dies without a verdict
440
+ excluded), and the sentence names the test. Found while testing the
441
+ derived-test marker, whose test wafer happened to have exactly this shape.
442
+ - **Merged-region findings used a singular verb:** *"Rings 1–2 has HBin 3
443
+ occurrence…"*, *"Quadrants NE & SE has…"*. A merged region is plural; bin and
444
+ functional pass-rate sentences now say "have". The Summary panel, which strips
445
+ the region from a row shown under its own group header, accepts either verb.
446
+ - **A `NaN` test value was classified as in-spec.** The spec-limit rule existed in
447
+ two places — `classifySpec` (die colouring, ▽/△ markers, the legend's spec tally)
448
+ and a private `specJudgement` in `stats/testPassRate.ts` (spec pass-rate tables) —
449
+ and they disagreed on two cases. `classifySpec` compared with `<` and `>`, which
450
+ are both false for `NaN`, so a non-finite measurement fell through to `'pass'`:
451
+ the exact "never drawn as plain in-spec" failure its own doc comment described.
452
+ It also reported `'pass'` for a test declaring no limits at all, inflating a spec
453
+ pass rate to 100% for every unlimited test. There is now one implementation
454
+ (`renderer/spec.ts`), strict on both, used by the renderer, stats and the
455
+ `specPass[n]` accessor.
456
+
25
457
  ## [0.30.2] — 2026-09-20
26
458
 
27
459
  ### Added
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![CI and deploy](https://github.com/wafertools/wafermap/actions/workflows/deploy.yml/badge.svg)](https://github.com/wafertools/wafermap/actions/workflows/deploy.yml)
6
6
  [![npm](https://img.shields.io/npm/v/@wafertools/wafermap.svg)](https://www.npmjs.com/package/@wafertools/wafermap)
7
7
  ![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
8
- ![bundle](https://img.shields.io/badge/data%20layer%20min%2Bgz-~53%20kB-blue)
8
+ ![bundle](https://img.shields.io/badge/data%20layer%20min%2Bgz-~61%20kB-blue)
9
9
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
10
10
 
11
11
  <img src="docs/images/hero-test-values.png" alt="wafermap demo" style="max-width:640px; display:block; margin:8px 0;" />
@@ -13,7 +13,7 @@
13
13
  Browser-first wafer map visualization for semiconductor test data.
14
14
 
15
15
  **Zero runtime dependencies.** Pure ES modules with TypeScript types — works in React,
16
- Svelte, Vue, plain HTML, or a Web Worker. The DOM-free data-and-stats layer is ~53 kB
16
+ Svelte, Vue, plain HTML, or a Web Worker. The DOM-free data-and-stats layer is ~61 kB
17
17
  min+gz; the interactive renderer is larger, and its chart suite and in-app guide are
18
18
  loaded on demand rather than shipped up front — [measured sizes](docs/performance.md).
19
19
 
@@ -32,6 +32,9 @@ wafermap renders interactive wafer maps from semiconductor prober output. Hard b
32
32
  - `renderWaferMap` — interactive canvas map with toolbar, zoom/pan, tooltips, die selection, and summary panel
33
33
  - `renderWaferGallery` — lot-level card grid with shared controls and click-to-expand
34
34
  - Insights tab (`insights: { enabled: true }`) — an in-toolbar chart suite (yield, per-test pass rate, bin pareto, capability, boxplot, histogram, wafer-to-wafer trend, correlation, scatter) computed from the same wafer/lot data, for one wafer or the whole gallery
35
+ - Derived tests (`derivedTests`) — tests computed per die from other tests (`abs(t[1020] - t[1010])`), which then work everywhere a measured test does, marked † as not measured
36
+ - Parametric sweeps (`insights.sweeps`) — an ordered run of tests read as a response curve, with the crossing and widths between two curves measured; x values given directly or read from the test names, on a linear or log axis
37
+ - Drilldown — right-click selected dies, a wafer card or a wafer's bar to chart just that population, with the population stated on the chart
35
38
  - `analyzeWaferMap` / `analyzeWaferLot` — spatial analysis across rings, quadrants, sectors, and reticle positions; failure cluster detection; lot trend series
36
39
  - Pure ES modules, no server, no runtime dependencies — works in React, Svelte, Vue, plain HTML, or a Web Worker
37
40
 
@@ -72,7 +75,7 @@ export. Add `analyzeWaferMap` for the findings and summary panel, swap in
72
75
 
73
76
  The API reference is long because it documents every option, not because you need
74
77
  them: [tsmap](https://github.com/wafertools/tsmap), a complete cross-platform desktop
75
- application built on this library, imports **15** of its ~100 exports. Read the
78
+ application built on this library, imports **17** of its ~100 exports. Read the
76
79
  [Quick Start](https://wafertools.github.io/wafermap/quickstart/) first and treat the
77
80
  [API reference](https://wafertools.github.io/wafermap/api/) as something to search,
78
81
  not to read.
@@ -0,0 +1,48 @@
1
+ import type { Die } from '../core/dies.js';
2
+ import { type TestDef } from '../renderer/buildWaferMap.js';
3
+ import type { SweepSpec } from '../stats/sweep.js';
4
+ /** One wafer's share of a population. */
5
+ export interface DrilldownItem {
6
+ /** The wafer's display label. */
7
+ label: string;
8
+ dies: Die[];
9
+ waferIndex?: number;
10
+ }
11
+ /** A population a chart can be opened on. */
12
+ export interface DrilldownSource {
13
+ /** One entry per wafer the population spans. A snapshot: the chart keeps
14
+ * these dies whatever happens to the selection afterwards. */
15
+ items: DrilldownItem[];
16
+ /** Who the dies are, read after a count — `"selected on W03"`, `"on W03"`. */
17
+ population: string;
18
+ testDefs: TestDef[] | undefined;
19
+ /** The test on screen where the gesture happened — a chart that shows one
20
+ * test at a time opens on it. */
21
+ activeTest?: number;
22
+ /** Set when the dies are not measured dies (a lot stack's per-position
23
+ * aggregates): every chart is then unavailable, with this as the reason. */
24
+ notMeasuredReason?: string;
25
+ }
26
+ /**
27
+ * Whether any drilldown chart could exist for this data — decides whether a
28
+ * right-click is taken over at all, without loading the drilldown chunk. The
29
+ * distribution charts need a parametric test, a sweep needs to be defined;
30
+ * with neither (a bins-only map) right-click stays the browser's or host's.
31
+ * `targetsFor` in drilldown.ts is the list this summarises.
32
+ */
33
+ export declare function hasDrilldownTargets(testDefs: readonly TestDef[] | undefined, sweeps: readonly SweepSpec[] | undefined): boolean;
34
+ interface WaferFacts {
35
+ /** The wafer's real identity, if it has one — never a positional stand-in,
36
+ * which would read as an ID ("Wafer 3 (no ID)") in a chart title. */
37
+ waferLabel: string | undefined;
38
+ testDefs: TestDef[] | undefined;
39
+ isLotStack?: boolean;
40
+ activeTest?: number;
41
+ waferIndex?: number;
42
+ }
43
+ /** Dies the user selected on one wafer's map. */
44
+ export declare function selectionPopulation(selected: Die[], f: WaferFacts): DrilldownSource;
45
+ /** Every die of one wafer. */
46
+ export declare function waferPopulation(dies: Die[], f: WaferFacts): DrilldownSource;
47
+ export {};
48
+ //# sourceMappingURL=chartPopulation.d.ts.map
@@ -0,0 +1 @@
1
+ import{isParametricTest as s}from"../renderer/buildWaferMap.js";export function hasDrilldownTargets(t,e){return(e?.length??0)>0||(t??[]).some(s)}const n="This map stacks a lot: its dies are per-position aggregates, not measured dies";function r(t,e,a){return{items:[{label:a.waferLabel??"this wafer",dies:t,waferIndex:a.waferIndex}],population:e,testDefs:a.testDefs,activeTest:a.activeTest,notMeasuredReason:a.isLotStack?n:void 0}}export function selectionPopulation(t,e){return r(t,e.waferLabel?`selected on ${e.waferLabel}`:"selected",e)}export function waferPopulation(t,e){return r(t,e.waferLabel?`on ${e.waferLabel}`:"on this wafer",e)}
@@ -1,5 +1,5 @@
1
1
  import type { ChartDatum } from '../../stats/yield.js';
2
- import { type SaveImageHandler } from './chartShell.js';
2
+ import { type SaveImageHandler, type WaferContextMenuHandler } from './chartShell.js';
3
3
  export interface ChartPanel {
4
4
  title: string;
5
5
  data: ChartDatum[];
@@ -59,6 +59,9 @@ export interface ChartPanel {
59
59
  * instead, via `drill.onOpenGroup` — this is never called for those.
60
60
  */
61
61
  onOpen?: (datum: ChartDatum) => void;
62
+ /** Right-click on a leaf row that carries a wafer (`datum.key`) — see
63
+ * `WaferContextMenuHandler`. Not called for a group row. */
64
+ onWaferContextMenu?: WaferContextMenuHandler;
62
65
  /** Document to build this panel's DOM into. Default `document` — pass the
63
66
  * host's own `ownerDocument` when the container might live in a
64
67
  * different document (e.g. a gallery card detached into its own popup
@@ -1 +1 @@
1
- import{SPACE as nt,fontPx as ot,FONT as lt,CLR as it}from"../toolbar.js";import{cardShell as rt,formatValue as st,observeResize as ct,makeTooltip as at,positionChartTooltip as ft,makeBackButton as ht,makeSegmented as dt,growCardToFitContent as ut,resolveChartCanvasColors as mt,PADDING as f,VALUE_WIDTH as z,prepareCanvas as xt}from"./chartShell.js";import{escHtml as _}from"../../core/utils.js";const h=24,m=5,bt=15,N=110,pt=12;export function renderBarPanel(i,U){const{barColor:P,valueLabel:G}=i;let b=i.title,l=i.data;const{card:r,heading:R,controlsRow:I,body:A}=rt(b,U,i.ownerDocument);if(i.selfControl){const e=i.selfControl;I.appendChild(dt(e.options,e.current,n=>q(n),r.ownerDocument))}const{drill:d}=i;let y=!1,B=null;const L=r.ownerDocument.createElement("div");Object.assign(L.style,{color:it.label,fontSize:lt.body,marginBottom:nt.sm}),r.insertBefore(L,A);function D(){const e=[];i.onOpen&&e.push("click to open this wafer"),d&&!y&&e.push(`click a ${d.groupLabelText} to see it by wafer`);const n=e.join(", or ");let o=n?`${n[0].toUpperCase()}${n.slice(1)}.`:"";const t=i.reference?.(l);t&&(o=o?`${o} Dashed line: ${t.label}.`:`Dashed line: ${t.label}.`),L.textContent=o}D();const w=r.ownerDocument.createElement("div"),v=()=>i.reference?bt:0,E=()=>f*2+v()+Math.min(l.length,pt)*(h+m);Object.assign(w.style,{overflowX:"hidden",overflowY:"auto",minHeight:"0",flex:"1",maxHeight:`${E()}px`,scrollbarGutter:"stable"}),A.appendChild(w);const a=r.ownerDocument.createElement("canvas");a.style.display="block",a.style.cursor="default",w.appendChild(a);const k=at(r);let x=-1,M=Math.max(1,...l.map(e=>e.value));const Y=e=>G?G(e):`${st(e.value)} (${e.percent.toFixed(1)}%)`;function F(e){const n=f+v()+e*(h+m),o=f+N,t=a.clientWidth-o-z-f;return{y:n,barX:o,barMaxWidth:Math.max(10,t)}}function p(){w.style.maxHeight=`${E()}px`,ut(r,A,E());const e=w.clientWidth,n=f*2+v()+l.length*(h+m),o=xt(a,r,e,n);if(!o)return;const{ctx:t}=o;t.font=`${ot(-1)}px system-ui, sans-serif`,t.textBaseline="middle";const s=mt(r);l.forEach((c,C)=>{const{y:u,barX:T,barMaxWidth:W}=F(C);C===x&&(t.fillStyle=s.bgHover,t.fillRect(0,u-m/2,e,h+m)),t.fillStyle=s.text,t.textAlign="right";const H=c.label.length>16?`${c.label.slice(0,15)}\u2026`:c.label;t.fillText(H,f+N-8,u+h/2),t.fillStyle=s.track,t.fillRect(T,u,W,h);const S=Math.max(1,c.value/M*W);t.fillStyle=P?P(c,C):s.text,t.fillRect(T,u,S,h),t.fillStyle=s.text,t.textAlign="right",t.fillText(Y(c),T+W+z,u+h/2)});const g=i.reference?.(l);if(g&&l.length){const{barX:c,barMaxWidth:C}=F(0),u=Math.round(c+Math.max(0,Math.min(1,g.value/M))*C)+.5,T=f+v()-3,W=f+v()+l.length*(h+m)-m,H=(Z,tt,et)=>{t.save(),t.strokeStyle=Z,t.lineWidth=tt,t.setLineDash(et),t.beginPath(),t.moveTo(u,T),t.lineTo(u,W),t.stroke(),t.restore()};H(s.bg,3.5,[]),H(s.text,1.5,[4,3]),t.save(),t.font="600 10px system-ui, sans-serif",t.textBaseline="middle",t.textAlign="left";const S=4,O=t.measureText(g.label).width+S*2,X=13,j=f+v()-X-1;let $=u-O/2;$=Math.max(c,Math.min($,c+C-O)),t.fillStyle=s.bg,t.strokeStyle=s.border,t.lineWidth=1,t.beginPath(),t.rect($,j,O,X),t.fill(),t.stroke(),t.fillStyle=s.text,t.fillText(g.label,$+S,j+X/2+.5),t.restore()}}function q(e){if(!i.selfControl)return;i.selfControl.current=e;const n=i.selfControl.onChange(e);l=n.data,M=Math.max(1,...l.map(o=>o.value)),x=-1,n.title&&(b=n.title,R.textContent=b),D(),p()}function J(e){if(!d)return;const n=d.onOpenGroup(e);l=n.data,b=n.title,R.textContent=b,M=Math.max(1,...l.map(o=>o.value)),x=-1,y||(y=!0,B=ht(()=>K(),r.ownerDocument),I.appendChild(B)),D(),p()}function K(){if(!d)return;const e=d.onBack();l=e.data,b=e.title,R.textContent=b,M=Math.max(1,...l.map(n=>n.value)),x=-1,y=!1,B?.remove(),B=null,D(),p()}function V(e){const n=Math.floor((e-f+m/2)/(h+m));return n>=0&&n<l.length?n:-1}a.addEventListener("mousemove",e=>{const n=a.getBoundingClientRect(),o=V(e.clientY-n.top),t=o>=0&&d&&!y&&l[o].itemCount>1,s=o>=0&&(t||!!i.onOpen&&o>=0&&!t);if(o!==x&&(x=o,a.style.cursor=s?"pointer":"default",p()),o>=0){const g=l[o],c=t?`<br><em>click to see this ${_(d.groupLabelText)} by wafer</em>`:i.onOpen?"<br><em>click to open this wafer</em>":"";k.innerHTML=`<strong>${_(g.label)}</strong><br>${_(Y(g))}${c}`,k.style.display="block",ft(k,r,e.clientX,e.clientY)}else k.style.display="none"}),a.addEventListener("mouseleave",()=>{x!==-1&&(x=-1,p()),k.style.display="none"}),a.addEventListener("click",e=>{const n=a.getBoundingClientRect(),o=V(e.clientY-n.top);if(o===-1)return;const t=l[o];if(d&&!y&&t.itemCount>1){J(t);return}i.onOpen?.(t)});const Q=ct(r,()=>p());return p(),{card:r,destroy:()=>Q.disconnect()}}
1
+ import{SPACE as nt,fontPx as ot,FONT as lt,CLR as it}from"../toolbar.js";import{cardShell as rt,formatValue as st,observeResize as ct,makeTooltip as at,positionChartTooltip as ft,makeBackButton as ut,makeSegmented as dt,fitRowsHeight as ht,setChartGrow as mt,resolveChartCanvasColors as xt,PADDING as u,VALUE_WIDTH as j,WAFER_MENU_HINT as bt,prepareCanvas as pt}from"./chartShell.js";import{escHtml as X}from"../../core/utils.js";const d=24,m=5,gt=15,z=110,yt=12;export function renderBarPanel(l,U){const{barColor:G,valueLabel:I}=l;let g=l.title,i=l.data;const{card:r,heading:E,controlsRow:P,body:S}=rt(g,U,l.ownerDocument);if(mt(r,"rows"),l.selfControl){const e=l.selfControl;P.appendChild(dt(e.options,e.current,n=>q(n),r.ownerDocument))}const{drill:f}=l;let y=!1,R=null;const A=r.ownerDocument.createElement("div");Object.assign(A.style,{color:it.label,fontSize:lt.body,marginBottom:nt.sm}),r.insertBefore(A,S);function B(){const e=[];l.onOpen&&e.push("click to open this wafer"),f&&!y&&e.push(`click a ${f.groupLabelText} to see it by wafer`);const n=e.join(", or ");let o=n?`${n[0].toUpperCase()}${n.slice(1)}.`:"";const t=l.reference?.(i);t&&(o=o?`${o} Dashed line: ${t.label}.`:`Dashed line: ${t.label}.`),A.textContent=o}B();const k=r.ownerDocument.createElement("div"),v=()=>l.reference?gt:0,Y=()=>u*2+v()+Math.min(i.length,yt)*(d+m);Object.assign(k.style,{overflowX:"hidden",overflowY:"auto",minHeight:"0",flex:"1",maxHeight:`${Y()}px`,scrollbarGutter:"stable"}),S.appendChild(k);const s=r.ownerDocument.createElement("canvas");s.style.display="block",s.style.cursor="default",k.appendChild(s);const w=at(r);let x=-1,M=Math.max(1,...i.map(e=>e.value));const N=e=>I?I(e):`${st(e.value)} (${e.percent.toFixed(1)}%)`;function F(e){const n=u+v()+e*(d+m),o=u+z,t=s.clientWidth-o-j-u;return{y:n,barX:o,barMaxWidth:Math.max(10,t)}}function C(){const e=u*2+v()+i.length*(d+m);k.style.maxHeight=`${ht(r,S,Y(),e)}px`;const n=k.clientWidth,o=pt(s,r,n,e);if(!o)return;const{ctx:t}=o;t.font=`${ot(-1)}px system-ui, sans-serif`,t.textBaseline="middle";const c=xt(r);i.forEach((a,p)=>{const{y:h,barX:W,barMaxWidth:H}=F(p);p===x&&(t.fillStyle=c.bgHover,t.fillRect(0,h-m/2,n,d+m)),t.fillStyle=c.text,t.textAlign="right";const T=a.label.length>16?`${a.label.slice(0,15)}\u2026`:a.label;t.fillText(T,u+z-8,h+d/2),t.fillStyle=c.track,t.fillRect(W,h,H,d);const $=Math.max(1,a.value/M*H);t.fillStyle=G?G(a,p):c.text,t.fillRect(W,h,$,d),t.fillStyle=c.text,t.textAlign="right",t.fillText(N(a),W+H+j,h+d/2)});const b=l.reference?.(i);if(b&&i.length){const{barX:a,barMaxWidth:p}=F(0),h=Math.round(a+Math.max(0,Math.min(1,b.value/M))*p)+.5,W=u+v()-3,H=u+v()+i.length*(d+m)-m,T=(Z,tt,et)=>{t.save(),t.strokeStyle=Z,t.lineWidth=tt,t.setLineDash(et),t.beginPath(),t.moveTo(h,W),t.lineTo(h,H),t.stroke(),t.restore()};T(c.bg,3.5,[]),T(c.text,1.5,[4,3]),t.save(),t.font="600 10px system-ui, sans-serif",t.textBaseline="middle",t.textAlign="left";const $=4,O=t.measureText(b.label).width+$*2,_=13,V=u+v()-_-1;let D=h-O/2;D=Math.max(a,Math.min(D,a+p-O)),t.fillStyle=c.bg,t.strokeStyle=c.border,t.lineWidth=1,t.beginPath(),t.rect(D,V,O,_),t.fill(),t.stroke(),t.fillStyle=c.text,t.fillText(b.label,D+$,V+_/2+.5),t.restore()}}function q(e){if(!l.selfControl)return;l.selfControl.current=e;const n=l.selfControl.onChange(e);i=n.data,M=Math.max(1,...i.map(o=>o.value)),x=-1,n.title&&(g=n.title,E.textContent=g),B(),C()}function J(e){if(!f)return;const n=f.onOpenGroup(e);i=n.data,g=n.title,E.textContent=g,M=Math.max(1,...i.map(o=>o.value)),x=-1,y||(y=!0,R=ut(()=>K(),r.ownerDocument),P.appendChild(R)),B(),C()}function K(){if(!f)return;const e=f.onBack();i=e.data,g=e.title,E.textContent=g,M=Math.max(1,...i.map(n=>n.value)),x=-1,y=!1,R?.remove(),R=null,B(),C()}function L(e){const n=Math.floor((e-u+m/2)/(d+m));return n>=0&&n<i.length?n:-1}s.addEventListener("mousemove",e=>{const n=s.getBoundingClientRect(),o=L(e.clientY-n.top),t=o>=0&&f&&!y&&i[o].itemCount>1,c=o>=0&&(t||!!l.onOpen&&o>=0&&!t);if(o!==x&&(x=o,s.style.cursor=c?"pointer":"default",C()),o>=0){const b=i[o],a=t?`<br><em>click to see this ${X(f.groupLabelText)} by wafer</em>`:l.onOpen?"<br><em>click to open this wafer</em>":"",p=!t&&l.onWaferContextMenu&&typeof b.key=="number"?bt:"";w.innerHTML=`<strong>${X(b.label)}</strong><br>${X(N(b))}${a}${p}`,w.style.display="block",ft(w,r,e.clientX,e.clientY)}else w.style.display="none"}),s.addEventListener("mouseleave",()=>{x!==-1&&(x=-1,C()),w.style.display="none"}),s.addEventListener("click",e=>{const n=s.getBoundingClientRect(),o=L(e.clientY-n.top);if(o===-1)return;const t=i[o];if(f&&!y&&t.itemCount>1){J(t);return}l.onOpen?.(t)}),s.addEventListener("contextmenu",e=>{const n=s.getBoundingClientRect(),o=L(e.clientY-n.top);if(o===-1||!l.onWaferContextMenu)return;const t=i[o];f&&!y&&t.itemCount>1||typeof t.key!="number"||(w.style.display="none",l.onWaferContextMenu(t.key,void 0,e))});const Q=ct(r,()=>C());return C(),{card:r,destroy:()=>Q.disconnect()}}
@@ -1,6 +1,6 @@
1
1
  import { type BoxplotItem } from '../../stats/boxplot.js';
2
2
  import type { TestDef } from '../../renderer/buildWaferMap.js';
3
- import { type AxisPrefs, type SaveImageHandler } from './chartShell.js';
3
+ import { type AxisPrefs, type SaveImageHandler, type WaferContextMenuHandler } from './chartShell.js';
4
4
  /** A `BoxplotItem` carrying enough identity for the panel to open its wafer
5
5
  * on a leaf-row click — the tab's own item shape already has this. */
6
6
  export type BoxplotPanelItem = BoxplotItem & {
@@ -43,6 +43,9 @@ export interface BoxplotPanelOptions {
43
43
  * same test in value mode instead of defaulting to hard-bin mode.
44
44
  * Never called for a pooled group-overview row (that drills instead). */
45
45
  onOpen?: (waferIndex: number, testNumber: number) => void;
46
+ /** Right-click on a wafer's box — see `WaferContextMenuHandler`. Not called
47
+ * for a pooled group row. */
48
+ onWaferContextMenu?: WaferContextMenuHandler;
46
49
  /** What `onOpen` will actually do, in the user's words — substituted into
47
50
  * both click affordances ("click a box to …" / "click to …"). Default
48
51
  * wording describes opening that wafer, which is what a gallery host does;