@wafertools/wafermap 0.30.2 → 0.30.3

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 (82) hide show
  1. package/AGENTS.md +16 -5
  2. package/CHANGELOG.md +384 -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 +81 -7
  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.js +1 -1
  18. package/dist/packages/canvas-adapter/charts/sweep.d.ts +31 -0
  19. package/dist/packages/canvas-adapter/charts/sweep.js +1 -0
  20. package/dist/packages/canvas-adapter/charts/trend.d.ts +3 -1
  21. package/dist/packages/canvas-adapter/charts/trend.js +1 -1
  22. package/dist/packages/canvas-adapter/dieList.js +2 -2
  23. package/dist/packages/canvas-adapter/drilldown.d.ts +23 -0
  24. package/dist/packages/canvas-adapter/drilldown.js +1 -0
  25. package/dist/packages/canvas-adapter/icons.js +1 -1
  26. package/dist/packages/canvas-adapter/insightsTab.d.ts +55 -3
  27. package/dist/packages/canvas-adapter/insightsTab.js +1 -1
  28. package/dist/packages/canvas-adapter/renderWaferGallery.js +1 -1
  29. package/dist/packages/canvas-adapter/renderWaferMap.js +1 -1
  30. package/dist/packages/canvas-adapter/summaryPanel.js +3 -3
  31. package/dist/packages/canvas-adapter/toCanvas.js +1 -1
  32. package/dist/packages/canvas-adapter/toolbar.d.ts +30 -2
  33. package/dist/packages/canvas-adapter/toolbar.js +2 -2
  34. package/dist/packages/canvas-adapter/userGuideHtml.d.ts +1 -1
  35. package/dist/packages/canvas-adapter/userGuideHtml.js +126 -3
  36. package/dist/packages/canvas-adapter/version.d.ts +2 -2
  37. package/dist/packages/canvas-adapter/version.js +1 -1
  38. package/dist/packages/canvas-adapter/warnings.js +1 -1
  39. package/dist/packages/renderer/axisTicks.d.ts +32 -0
  40. package/dist/packages/renderer/axisTicks.js +1 -0
  41. package/dist/packages/renderer/buildView.d.ts +11 -37
  42. package/dist/packages/renderer/buildView.js +1 -1
  43. package/dist/packages/renderer/buildWaferMap.d.ts +58 -1
  44. package/dist/packages/renderer/buildWaferMap.js +1 -1
  45. package/dist/packages/renderer/deprecate.d.ts +13 -5
  46. package/dist/packages/renderer/deprecate.js +1 -1
  47. package/dist/packages/renderer/derivedTests/apply.d.ts +85 -0
  48. package/dist/packages/renderer/derivedTests/apply.js +1 -0
  49. package/dist/packages/renderer/derivedTests/evaluate.d.ts +48 -0
  50. package/dist/packages/renderer/derivedTests/evaluate.js +1 -0
  51. package/dist/packages/renderer/derivedTests/parser.d.ts +148 -0
  52. package/dist/packages/renderer/derivedTests/parser.js +2 -0
  53. package/dist/packages/renderer/fmt.d.ts +24 -2
  54. package/dist/packages/renderer/fmt.js +1 -1
  55. package/dist/packages/renderer/index.d.ts +1 -0
  56. package/dist/packages/renderer/index.js +1 -1
  57. package/dist/packages/renderer/spec.d.ts +49 -0
  58. package/dist/packages/renderer/spec.js +1 -0
  59. package/dist/packages/renderer/testLabel.d.ts +154 -0
  60. package/dist/packages/renderer/testLabel.js +1 -0
  61. package/dist/packages/stats/analyzeWaferMap.js +1 -1
  62. package/dist/packages/stats/capability.d.ts +4 -0
  63. package/dist/packages/stats/capability.js +1 -1
  64. package/dist/packages/stats/correlation.d.ts +3 -0
  65. package/dist/packages/stats/correlation.js +1 -1
  66. package/dist/packages/stats/deprecated.d.ts +3 -0
  67. package/dist/packages/stats/deprecated.js +1 -1
  68. package/dist/packages/stats/index.d.ts +2 -2
  69. package/dist/packages/stats/index.js +1 -1
  70. package/dist/packages/stats/mergeTestDefs.js +1 -1
  71. package/dist/packages/stats/renderFindingsReport.js +8 -28
  72. package/dist/packages/stats/renderSummaryReport.js +21 -30
  73. package/dist/packages/stats/reportHtml.d.ts +27 -0
  74. package/dist/packages/stats/reportHtml.js +35 -14
  75. package/dist/packages/stats/sweep.d.ts +178 -0
  76. package/dist/packages/stats/sweep.js +1 -0
  77. package/dist/packages/stats/sweepXFromName.d.ts +27 -0
  78. package/dist/packages/stats/sweepXFromName.js +1 -0
  79. package/dist/packages/stats/testPassRate.js +1 -1
  80. package/dist/packages/stats/types.d.ts +28 -1
  81. package/llms.txt +3 -2
  82. 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,390 @@ under `### Breaking`.
22
22
 
23
23
  ---
24
24
 
25
+ ## [0.30.3] — 2026-09-23
26
+
27
+ ### Added
28
+
29
+ - **Sweep x values from test names, an x unit, and a log x axis.** For a
30
+ program that records the swept quantity only in the test text — a
31
+ resistance CDF named `Normalized_LRS= LRS_STATS_12K / …`, one test per
32
+ threshold:
33
+ - `SweepSeriesSpec.xFromName` reads each test's x from its name with a
34
+ placeholder pattern, `"LRS_STATS_{x}"`: `{x}` reads a number with its SI
35
+ prefix (`12K` → 12,000; case-sensitive, `K` accepted as kilo; a letter is a
36
+ prefix only alone or before a unit, so `12Kangaroos` reads 12; in an all-capitals
37
+ name the unit's case is gone, so `5NS` is 5 ns and `2MV` — milli or mega? — is
38
+ reported, not guessed), `*` matches
39
+ anything, literal text matches in any case, anywhere in the name. Not a
40
+ regular expression: a sweeps file is shared, and JavaScript cannot
41
+ interrupt a runaway regex, whereas this matcher's cost is bounded whatever
42
+ the pattern (`stats/sweepXFromName.ts`). Each x stays attached to its own
43
+ test, so a missing test loses one point instead of shifting the rest.
44
+ - `SweepSpec.xUnit` prints the x axis, crossing and widths SI-prefixed
45
+ (`47.3 kΩ`), trailing zeros dropped (`2 kΩ`, not `2.00 kΩ`).
46
+ - `SweepSpec.xScale: 'log'` places x on a log axis; the crossing is
47
+ interpolated along log x and a width is reported as a ratio with both ends
48
+ (`×2.49 (15.9 kΩ → 39.7 kΩ)`), via new `SweepSeparation.ratio`/`from`/`to`.
49
+ `SweepData.xScale` is the axis actually drawn: a non-positive x keeps it
50
+ linear, with a warning.
51
+ - Reported, never guessed, and then not measured: a name the pattern does not
52
+ fit, a series giving both `xValues` and `xFromName`, and — for every x
53
+ source — a series that revisits an x value, which the crossing previously
54
+ mis-paired silently.
55
+
56
+ - **Drilldown: chart a selection or a wafer.** Right-click a population for a
57
+ menu of charts drawn from just those dies, opened in the same modal as an
58
+ expanded Insights card:
59
+ - **Populations:** the dies selected on a map (a single map or any gallery
60
+ card); a whole wafer, from empty map space with nothing selected, from a
61
+ gallery card anywhere outside its map, or from one wafer's bar, box or point
62
+ in *Yield by wafer*, *Test value distribution* or *Wafer-to-wafer trend* in
63
+ Insights (their tooltips say "right-click to chart this wafer"). The Menu
64
+ key, Shift+F10 and a new map toolbar **Chart** button (the selection if
65
+ there is one, else the wafer) reach the same menu without a mouse.
66
+ - **Charts:** value histogram (opening on the test the map shows), process
67
+ capability, and the sweeps defined in `insights.sweeps` — the gallery now
68
+ passes those to each card, and a single map offers them whether or not
69
+ `insights.enabled` is set.
70
+ - **Population stated on every chart**, in the modal title and on the card
71
+ ("12 dies selected on W03", "10 of 12 … (partial and edge-excluded dies left
72
+ out)"). Capability below 30 dies adds that each Ppk is a rough estimate.
73
+ Charts are snapshots: changing the selection afterwards does not change them.
74
+ The wafer is named by the item's `label`, else its `metadata.waferId` — never a
75
+ positional "Wafer 3 (no ID)" — on the map, the gallery and Insights alike.
76
+ - **The library decides what is offered:** a chart that cannot be drawn stays
77
+ listed, greyed, with the reason (no values for its tests; fewer than two
78
+ dies for capability; a lot-stack map, whose dies are per-position aggregates
79
+ rather than measured dies). Right-clicking an unselected die selects it
80
+ first. A map with no parametric tests and no sweeps leaves right-click to
81
+ the browser or host, and the map canvas owns right-click on itself: one it
82
+ declines (the bin legend) never reaches a gallery card or host handler.
83
+ - Documented in api.md §5.12, the user guide §4.4 (with screenshots from
84
+ `capture-definitions.mjs`) and the developer guide.
85
+ - Built on one source/target mechanism (`canvas-adapter/chartPopulation.ts`
86
+ builds the populations, `drilldown.ts` the menu and charts, loaded on first
87
+ use and pinned by `tests/bundle-size.test.mjs`), so a further population or
88
+ chart is one more of either rather than a menu per pairing.
89
+
90
+ - **Derived tests** — `WaferMapInput.derivedTests`: tests computed from other tests
91
+ on the same die, rather than measured. Each entry is a `TestDef` plus an
92
+ `expression`, and from the build onwards it is an ordinary test — it appears in
93
+ `result.testDefs`, in the value plot modes, the colorbar, tooltips,
94
+ `analyzeWaferMap`, Insights and the report, with no other change required.
95
+
96
+ ```ts
97
+ derivedTests: [
98
+ { testNumber: 900001, name: 'Leakage Shift', unit: 'uA',
99
+ expression: 'abs(t[1020] - t[1010])', limitHigh: 5 },
100
+ { testNumber: 900002, name: 'Sweep All Pass', testType: 'F',
101
+ expression: 'all(testPass[1010..1025])' },
102
+ ]
103
+ ```
104
+
105
+ Expressions read `t[n]` (measured value), `testPass[n]` (the tester's recorded
106
+ verdict), `specPass[n]` (spec-limit judgement) and `diePass()` (the die's bin
107
+ verdict) — the same `'test'`/`'spec'` distinction `passFailDisplay` already
108
+ draws, because they are different questions and a single accessor would have
109
+ silently meant "recorded verdict" for parametric data that has none. A range
110
+ (`t[1010..1015]`) yields a set that must be reduced by `mean`, `sum`, `min`,
111
+ `max`, `all`, `any`, `none`, `countTrue`, `countFalse` or `countKnown`; there is
112
+ no vector arithmetic, which is what stops the grammar becoming an array
113
+ language. Operators `+ - * / % ^`, comparisons, `and`/`or`/`not`, and
114
+ `if(cond, a, b)`.
115
+
116
+ Parsed to a typed tree at build time and walked per die — **no `eval`, no
117
+ `Function`, no third-party expression engine, and no way to name a host
118
+ object** — so a chart spec can be shared between teams as plain JSON. This is
119
+ why the root bundle grows ~5 KB gzip.
120
+
121
+ Evaluated on raw probe records **before** lot stacking and retest resolution, so
122
+ every derived value comes from one real touchdown: deriving after a stack would
123
+ subtract one aggregate from another, and deriving after retest collapse could mix
124
+ a value from one touchdown with a verdict from another.
125
+
126
+ A boolean expression is a verdict — declare `testType: 'F'` and the result lands
127
+ in `die.testPass`, never as a 1/0 in `testValues` where it would enter the
128
+ correlation matrix and the Cpk table. An unknown input yields an **absent** value
129
+ (no-data grey), never `0` and never `false`.
130
+
131
+ Everything statically knowable is checked at build and reported as a
132
+ `derived-test-invalid` warning naming the position — a parse or type error, a
133
+ `testNumber` colliding with measured data, a `testType` that disagrees with the
134
+ expression, `t[n]` on a functional test, `specPass[n]` on a test with no limits.
135
+ A rejected derived test is **dropped**, never half-applied.
136
+
137
+ - **Parametric sweeps** — `insights.sweeps` on `renderWaferMap`/`renderWaferGallery`:
138
+ an ordered run of tests read as a response curve rather than as independent
139
+ tests, with the crossing point of the first two series and the horizontal
140
+ separation at named levels measured and stated. `xValues` supplies the real
141
+ swept quantity so the crossing is reported in physical units; without it the
142
+ axis is the ordinal position, because test numbers are identifiers and are not
143
+ guaranteed to be evenly spaced. Each line is the population median with a
144
+ p10–p90 band. A multiple crossing says so; a level neither curve reaches reads
145
+ "not measurable" naming the series, never `0`.
146
+ - **Test ranges in a sweep's `tests`** — an entry may be `"1010..1030"`, the exact
147
+ syntax of a derived-test expression's `t[1010..1030]` and parsed by the same code
148
+ (`parseTestReference`), so the same text always names the same tests. A range
149
+ expands to the declared tests inside it, ascending — a program numbered in steps
150
+ of 2 needs no step syntax. `xValues` is checked against the expanded list: when
151
+ a test is missing from a range, the footer lists what the range matched and the
152
+ card is drawn in test order with the crossing and widths **not measured**
153
+ (`SweepData.notMeasured`), rather than sliding every later x value onto the
154
+ wrong test. The same now applies when only some series carry `xValues`, which
155
+ before put physical and ordinal x positions on one axis and reported an ordinal
156
+ crossing under the physical x label. A range resolves by filtering the declared
157
+ tests rather than counting through every integer, so `0..2000000000` in a shared
158
+ template is not a two-billion-step loop — in expressions too.
159
+ - **Nested derived tests** — a derived test may read another. Compilation
160
+ topologically sorts by what each expression reads, so declaration order does
161
+ not matter; cycles, self-references and dependents of a rejected test are
162
+ dropped with the cause named.
163
+ - New example: **Derived tests and sweeps** (`docs/examples/derived-tests.html`).
164
+ - **Sweeps have their own Insights sub-tab**, shown only when `insights.sweeps`
165
+ defines at least one; `InsightsView` gains `'sweeps'` for `defaultView`, which
166
+ falls back to `'overview'` when there are none. Not an option and not a count
167
+ threshold: a threshold would move a sweep between tabs the day a colleague
168
+ added another, and an option would be a layout flag with one right answer.
169
+ They never belonged in Distributions either — that view is driven by one
170
+ selected test (capability → boxplot → histogram → trend), while a sweep draws
171
+ many tests as one curve and ignores the selection.
172
+ - New example: **Parametric sweeps** (`docs/examples/sweeps.html`) — four
173
+ characterisation sweeps in the Sweeps tab, each a different use of the
174
+ crossing and the width: the temperature-inversion voltage (Fmax vs VDD at
175
+ 25/125 °C, with the iso-frequency VDD gap at two speeds), DIBL from an NMOS
176
+ transfer curve at two drain biases, the retention bake at which the read
177
+ window closes (uneven read times, which is what `xValues` is for), and an
178
+ output driver's maximum drive current (VOH vs VOL under load). The transfer
179
+ curve is swept over **derived** `log10(t[n])` tests — a sweep reads any test,
180
+ which is how a log-scale curve is drawn without a log option.
181
+ - **Derived tests are marked on the sweep card**: `SweepPoint` carries `derived`
182
+ and `expression`, ordinal tick labels keep the `†` in front of a truncated name, the
183
+ tooltip marks each series row and adds each expression, and the footer shows
184
+ the key.
185
+ - **Sweep card text.** The crossing line lower-cased the y label ("Cell Vt"
186
+ became "cell vt", "Fmax" became "fmax") — the width line had already been
187
+ fixed for the same reason ("dBm" → "dbm"); widths printed in engineering
188
+ notation (`32.7E-3`); and an x label carrying its own unit doubled the
189
+ brackets (`(VDD (V))`). X values — ticks, crossing and widths — now share one
190
+ plain-decimal rule, and a width reads "0.0327 on the VDD (V) axis".
191
+ - The derived-tests example opened on a Switching Window measured at the step
192
+ where its two curves cross, so the gap sat around zero against `limitLow: 0`
193
+ and nearly every die carried a ▽. It is now measured at a fixed read step
194
+ with a 0.33 V margin, so only an edge band fails.
195
+ - **A derived test is now identifiable from `result.testDefs` alone.** `derived`
196
+ was declared only on the `DerivedTestDef` *input* type, while `result.testDefs`
197
+ is a homogeneous `TestDef[]` — so every display surface needed a cast to ask
198
+ whether a value was measured or derived, and `expression`/`constants` were
199
+ stripped off the admitted def entirely, leaving nothing to say where a number
200
+ came from. All three now travel on `TestDef` as `readonly` fields set by
201
+ `buildWaferMap`. This is the prerequisite for marking derived tests in the UI:
202
+ a display that cannot ask the question cannot mark it, and an engineer reading a
203
+ Cpk table should never be left to assume a number came off the tester. The
204
+ alternative — hosts holding their own `derivedTests` input beside the result and
205
+ joining by test number — is a second source of truth that can disagree with the
206
+ def actually admitted, which carries the resolved `testType`.
207
+ - **`testLabel` / `isDerivedTest` / `derivedTestSource` / `hasDerivedTests`**
208
+ (`renderer/testLabel.ts`, internal) — one definition of how a test is named and
209
+ how a derived one is distinguished. The "name, or *Test N* when it has none"
210
+ fallback existed in six places (`stats/sweep.ts`, `capability.ts`, `testPassRate.ts`,
211
+ `analyzeWaferMap.ts`, `charts/chartShell.ts`), all now routed through it. Around
212
+ sixteen surfaces display a test name; a marker added per surface would have been
213
+ that rule twenty-odd times over, which is how two of them end up disagreeing.
214
+ - **Derived tests are marked in the process capability panel.** A `†` precedes
215
+ the test name, the legend gains the key "† Derived, not measured", and the
216
+ tooltip says the same in words and shows the expression the value was
217
+ computed from. A dagger, not `ƒ`: in this domain `f` reads as femto,
218
+ and a `ƒ` beside `fA`/`fF` units is a real misread.
219
+ `TestCapability`/`CapabilityDatum` gain `derived` and `expression`.
220
+ - **…and everywhere else a test is named, always in front of the name.** In a
221
+ list the marks then form a column down the left edge, so a derived test is found
222
+ at a glance, and truncating a long name can never cut the mark off; a list
223
+ holding a derived test pads its measured names by the mark's width so names stay
224
+ aligned, and a list without one is unchanged. The map title and colorbar
225
+ (`† Leak Shift (nA)`)
226
+ with the key on its own line beneath, drawn via a new optional
227
+ `MapTitleParts.note`; the map tooltip, which adds the key and the expression;
228
+ finding labels and sentences, marked at the source so no reader of a finding
229
+ can show it unmarked, with `StatsFinding.variable.derived`/`expression` as the
230
+ structured form; `stats.functionalYield[].label` (plus `derived`/`expression`);
231
+ the Summary panel's parametric and functional tables and its findings list,
232
+ each with a key line naming every expression; and both HTML reports, whose key
233
+ lists the expressions because a printed report has no hover. CSV exports carry
234
+ plain names plus a trailing **Derived from** column, only when a row is
235
+ derived — a CSV has nowhere to put a key. The key is a single short string,
236
+ *Derived, not measured*, so it fits the corner of a map and cannot drift
237
+ between surfaces. Every surface goes through `renderer/testLabel.ts`, which
238
+ also replaced two more hand-written *Test N* fallbacks in `buildView.ts` and
239
+ one in the gallery's stacked cards, whose synthetic def now keeps the flag.
240
+ The finding tooltip now has one rule, `formatFindingTooltip`, which the
241
+ Summary panel shares with both reports instead of using `summary` directly.
242
+ The places a test is *chosen* are marked too: the plot-mode test menu (a slot
243
+ in front of each name, reserved only when a derived test is listed, and a key
244
+ line at the foot), the Insights test pickers (`testOptionLabels`), the
245
+ correlation matrix's axis labels and tooltip (key on its hint line; its CSV
246
+ gains per-side *derived from* columns) and the die list's column headers (key
247
+ line above the table; the CSV header states the expression). The last two
248
+ hand-written *Test N* fallbacks, in the plot-mode menu and the die list, now go
249
+ through `testLabel`.
250
+ - **`DERIVED_MARK` and `DERIVED_KEY` are exported** (root and `/renderer`), so a
251
+ host listing tests in its own UI marks derived tests exactly as the library does
252
+ instead of keeping its own copy of the glyph and the words. tsmap's test selector
253
+ is the first use.
254
+ - Getting there turned up two places that dropped the flag. **`mergeTestDefs`**,
255
+ which builds a gallery's single test list, rebuilt each def field by field and
256
+ never named `derived`, so every cross-wafer panel would have shown a derived
257
+ test unmarked while the single-wafer view marked it. It now carries `derived`
258
+ and `expression`, marking a test derived when **any** wafer says so: marking
259
+ a measured test is an oddity someone queries, while leaving a derived one
260
+ unmarked goes unnoticed. The Summary panel's per-test table narrowed defs the
261
+ same way and now keeps both fields. It also held a seventh copy of the
262
+ *Test N* fallback, now routed through `testLabel`.
263
+
264
+ ### Changed
265
+
266
+ - **Docs and examples brought up to date with this release.** The developer
267
+ guide's sweep section no longer says `tests` has no range syntax or that a
268
+ sweep has no log option; it covers ranges, `xFromName`, `xUnit`, `xScale` and
269
+ drilldown. The sweeps example adds a fifth sweep (an RRAM resistance
270
+ distribution read from test names on a log axis) and gives the output-driver
271
+ sweep an x unit; the derived-tests example's sweep uses ranges and a real swept
272
+ quantity (pulse amplitude in volts) instead of ordinal steps, and places the †
273
+ in front of the name as everywhere else. The README, `AGENTS.md` and
274
+ `llms.txt` now mention derived tests, sweeps and drilldown.
275
+ Those two examples' code cards, a four-across grid that cut every code
276
+ block off at about half its width in 12 px type, now show one example per
277
+ row — the code whole, in monospace at 13.5 px, beside notes at 14.5 px,
278
+ stacked on narrow screens — from one shared style in `demo.css`. `--mono`,
279
+ which every example's code referenced and nothing defined, is now set.
280
+ The derived-tests example is now in two labelled parts — derived tests are
281
+ *data* (`buildWaferMap`'s `derivedTests`; tsmap's test-definitions file), a
282
+ sweep is a *chart* (the renderer's `insights.sweeps`; tsmap's separate sweeps
283
+ file) — because a run of look-alike snippets read as one list. Its snippets
284
+ show the whole call each belongs to, it says the sweep is in Insights →
285
+ Sweeps, with a *Show the sweep* button that presses the gallery's own Insights
286
+ button, and it links to the sweeps example. The sweeps example opens with
287
+ where its entries go.
288
+
289
+ - **`scripts/check-bundle-size.mjs` attributes lazy chunks by what imports
290
+ them, not by file name.** Once drilldown and Insights shared the chart panels,
291
+ esbuild split the shared code into an anonymous `chunk-*.js`, which a file-name
292
+ test counted as core. That would have added ~5 KB of lazy code to the "always
293
+ downloaded" figure. The guide, Insights and drilldown now count as their own
294
+ static closure minus what the entry already loads. The Insights figure now
295
+ includes the chart code it shares with drilldown, because opening Insights
296
+ downloads it.
297
+ - **Axis ticks sit on round values, and are labelled to their spacing.** Tick
298
+ labels used to take their decimals from each value's own size, never from the
299
+ gap between ticks, so a 1–10 axis read "2.00 4.00 6.00 8.00" and a bandgap's
300
+ 1.195–1.205 V read "1.20" five times — a sloped curve labelled as a flat line.
301
+ And only the colorbar placed ticks on round numbers: the box plot, scatter,
302
+ trend and sweep divided the data range into equal fractions (2.54 / 2.04 /
303
+ 1.53 GHz), the histogram labelled its bucket edges, and its count axis carried
304
+ an inline copy of the rounding rule with different breakpoints. Every numeric
305
+ axis now goes through `renderer/axisTicks.ts` — one 1-2-5 rounding rule
306
+ (`niceStep`), round ticks inside the data range, and labels with exactly the
307
+ decimals the step needs (`stepDecimals`): "2 4 6 8", "1.196 1.198 1.200".
308
+ **Density comes from the space, not a tick count:** `fitTicks` takes the
309
+ finest round step whose labels fit — measured label widths plus an 8 px gap
310
+ on a horizontal axis, a line spacing on a vertical one. A fixed count, tried
311
+ first, rounded a 200 px Idsat box plot (0.863–1.56 mA) to 1.0 / 1.2 / 1.4 —
312
+ 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
313
+ values, so they get one decimal more, dropped when it is a zero — a 9.87
314
+ maximum no longer rounds to "10". Log-scale and integer-valued colorbars, die
315
+ labels and limit labels keep size-based formatting; they are not tick grids.
316
+ Every chart's tick labels change, so doc screenshots need recapturing.
317
+ - **The wafer and lot reports' findings table now uses plain bin terms**
318
+ ("hard bin 2", not "HBin 2"). Both HTML reports render one findings table,
319
+ `findingsTableHtml`; each report module had kept its own, and only the
320
+ findings-only report translated the bin terms.
321
+
322
+ ### Deprecated
323
+
324
+ - **`renderFindingsReportHtml`** — removed in 0.31.0. Use
325
+ `renderWaferReportHtml(result, summary)` or `renderLotReportHtml(results)`:
326
+ their Findings section is the same table, alongside the population and yield
327
+ it was found in. It was kept in 0.30.1 on the grounds that the Summary panel
328
+ used it, which had not been true since 0.20.0; nothing in wmap or tsmap calls
329
+ it. It ships deprecated in this release, so it goes in 0.31.0 with the 0.30.0
330
+ deprecations. `deprecated()` now takes a removal version per name, for a name
331
+ deprecated too late to go with the rest.
332
+
333
+ ### Fixed
334
+
335
+ - **A menu row's hint is no longer drawn under its own menu.** Menus sit in a
336
+ dedicated layer (`menuLayerFor`) that outranks everything else in its root,
337
+ and the shared tooltip was placed beside that layer — so the reason on a
338
+ greyed-out row (the drilldown menu's, the Overlays menu's) appeared behind the
339
+ menu it explained. `positionTooltip` now places the tooltip inside the
340
+ anchor's menu layer at `Z_ABOVE2`, above every menu there, still resolved per
341
+ root (a host `<dialog>`, a wmap modal).
342
+
343
+ - **The map's hover tooltip no longer jumps away from the die.** The shared
344
+ tooltip steps clear of its anchor when the anchor is under 40% of the window
345
+ tall — right for a toolbar button, which its tip must not cover, and wrong
346
+ for the map canvas, where the pointer is on the die the tip describes. Every
347
+ map that small (every gallery card, the smaller maps on a page such as the
348
+ geometry example, any map in a tall window) had its tip thrown below or above
349
+ the whole canvas, measured at up to 266 px from the pointer. The map now
350
+ passes `followPointer` to `positionTooltip` and the tip stays beside the
351
+ pointer; control tips keep stepping around their buttons.
352
+
353
+ - **An expanded chart grows into its modal, in the direction that suits it.**
354
+ Several cards kept their grid-card size inside the expand modal: the trend
355
+ and sweep plots stayed a few hundred pixels tall, ring and quadrant yield
356
+ kept a fixed-size circle, the correlation matrix kept small cells, process
357
+ capability stopped at ~160 px per test, and row charts (yield by wafer,
358
+ pareto, pass rate, value distribution) left most of a 700 px box empty. Each
359
+ card now declares how it grows (`setChartGrow`): **plots** fill width and
360
+ height in a wider box; **row charts** keep bars full-width, show every row the
361
+ box has room for, and the box opens sized to its rows (within the viewport);
362
+ **square** charts grow to the shorter side in a square box. Capability's
363
+ columns spread across the width while each box keeps its grid maximum width.
364
+ Growth past the grid size happens only while expanded, so the Insights grid
365
+ is unchanged; maximise/restore and drag-resize shrink back correctly.
366
+ - **Chart cards measure their chrome, not their stretch.**
367
+ `growCardToFitContent` took the card's overhead as `card height − body
368
+ height`, which counts any stretch as overhead and grows the card on every
369
+ redraw — why several panels opted out of stretching (`alignSelf: 'start'`),
370
+ which is what kept them small in the modal. It now sums the card's other
371
+ children, so a stretched card is harmless. Trend and sweep no longer ask for
372
+ the height they were given as their minimum, which would have stopped a
373
+ shrinking modal.
374
+ - **Chart tooltips wrap inside their background.** They were `nowrap` under a
375
+ 280 px cap, so a longer line — a sweep row with its p10–p90 and n, a derived
376
+ test's expression — ran out past the dark box. They now wrap within 320 px,
377
+ breaking an unbroken expression if they must. The sweep tooltip puts each
378
+ series' spread and n on a line of their own under its median.
379
+
380
+ - **A functional-test finding merged across adjacent regions reported no
381
+ difference, and hid the real one.** Since 0.20.3, when a functional pass-rate
382
+ signal spanned adjacent rings, sectors or quadrants, the merge pass that joins
383
+ them into one finding (`Rings 1–2`) had no functional branch and fell through
384
+ to the bin one. It counted dies whose hard bin equalled `variable.bin` — which
385
+ a functional finding does not have — so both sides counted 0, and the merged
386
+ finding read *"Rings 1–2 has HBin undefined occurrence 0.0 percentage points
387
+ lower than the rest of the map"*, with a meaningless p-value. It also
388
+ **replaced** the correct per-region findings it merged, so a real functional
389
+ signal spanning adjacent regions was shown as no difference at all. The merge
390
+ now recomputes the pass rate over the merged region exactly as the per-region
391
+ builder does (verdicts through `getTestPassStatus`, dies without a verdict
392
+ excluded), and the sentence names the test. Found while testing the
393
+ derived-test marker, whose test wafer happened to have exactly this shape.
394
+ - **Merged-region findings used a singular verb:** *"Rings 1–2 has HBin 3
395
+ occurrence…"*, *"Quadrants NE & SE has…"*. A merged region is plural; bin and
396
+ functional pass-rate sentences now say "have". The Summary panel, which strips
397
+ the region from a row shown under its own group header, accepts either verb.
398
+ - **A `NaN` test value was classified as in-spec.** The spec-limit rule existed in
399
+ two places — `classifySpec` (die colouring, ▽/△ markers, the legend's spec tally)
400
+ and a private `specJudgement` in `stats/testPassRate.ts` (spec pass-rate tables) —
401
+ and they disagreed on two cases. `classifySpec` compared with `<` and `>`, which
402
+ are both false for `NaN`, so a non-finite measurement fell through to `'pass'`:
403
+ the exact "never drawn as plain in-spec" failure its own doc comment described.
404
+ It also reported `'pass'` for a test declaring no limits at all, inflating a spec
405
+ pass rate to 100% for every unlimited test. There is now one implementation
406
+ (`renderer/spec.ts`), strict on both, used by the renderer, stats and the
407
+ `specPass[n]` accessor.
408
+
25
409
  ## [0.30.2] — 2026-09-20
26
410
 
27
411
  ### 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-~59%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 ~59 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;