@datagrok/sequence-translator 1.10.21 → 1.10.23
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.
- package/CHANGELOG.md +12 -0
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +1 -1
- package/src/oligo-renderer/analog-cache.ts +5 -2
- package/src/oligo-renderer/canvas-renderer.ts +401 -123
- package/src/oligo-renderer/cell-actions.ts +178 -0
- package/src/oligo-renderer/legend-panel.ts +20 -12
- package/src/oligo-renderer/monomer-colors.ts +61 -0
- package/src/oligo-renderer/tooltip.ts +2 -2
- package/src/package-api.ts +21 -0
- package/src/package.g.ts +26 -2
- package/src/package.ts +57 -16
- package/src/polytool/pt-chem-enum-dialog.ts +6 -2
- package/src/tests/oligo-renderer-tests.ts +224 -14
- package/test-console-output-1.log +179 -179
- package/test-record-1.mp4 +0 -0
|
@@ -211,10 +211,158 @@ category('OligoRenderer: layout', () => {
|
|
|
211
211
|
const layout = computeLayout(500, 70, m);
|
|
212
212
|
expect(layout.textOnlyFallback, false);
|
|
213
213
|
expect(layout.chipW > 5, true, 'chip width should be larger than minimum');
|
|
214
|
-
|
|
214
|
+
// chipW is bounded above by the cell's height budget. V_PAD=10 is the
|
|
215
|
+
// top/bottom breathing room; the rest is divided among 2 strands +
|
|
216
|
+
// 1 strand-gap + 2 apex zones with the fixed chip aspect ratio.
|
|
217
|
+
const heightBudget = (70 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
|
|
218
|
+
expect(layout.chipW <= heightBudget + 0.01, true,
|
|
219
|
+
`chipW should be bounded by height (got ${layout.chipW}, cap ${heightBudget})`);
|
|
215
220
|
expect(layout.antiY > layout.senseY, true);
|
|
216
221
|
expect(layout.senseChips.length > 0, true);
|
|
217
222
|
expect(layout.antiChips.length > 0, true);
|
|
223
|
+
// Top apex must sit at least V_PAD=10 from the cell's top edge.
|
|
224
|
+
expect(layout.senseY - layout.apexH >= 10 - 0.01, true,
|
|
225
|
+
`top apex zone violates 10px top margin: senseY=${layout.senseY}, apexH=${layout.apexH}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('chipW grows with cell height (no hard max cap)', async () => {
|
|
229
|
+
// Same duplex, taller cells. Without a hardcoded MAX_CHIP_W cap, chipW
|
|
230
|
+
// must grow proportionally with cellH (height budget governs the cap).
|
|
231
|
+
// Smallest cell here is 60px to stay above the V_PAD=10 floor where
|
|
232
|
+
// the height budget would otherwise dip under MIN_CHIP_W.
|
|
233
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
234
|
+
const layoutSmall = computeLayout(1000, 60, m);
|
|
235
|
+
const layoutMid = computeLayout(1000, 100, m);
|
|
236
|
+
const layoutBig = computeLayout(1000, 180, m);
|
|
237
|
+
expect(layoutMid.chipW > layoutSmall.chipW, true,
|
|
238
|
+
`expected chipW to grow with height: ${layoutSmall.chipW} → ${layoutMid.chipW}`);
|
|
239
|
+
expect(layoutBig.chipW > layoutMid.chipW, true,
|
|
240
|
+
`expected chipW to keep growing: ${layoutMid.chipW} → ${layoutBig.chipW}`);
|
|
241
|
+
// Previously this was capped at MAX_CHIP_W=17. With the cap removed and
|
|
242
|
+
// a 180px-tall cell, chipW comfortably exceeds 30 even after V_PAD=10
|
|
243
|
+
// takes 20px out of the vertical budget.
|
|
244
|
+
expect(layoutBig.chipW > 30, true,
|
|
245
|
+
`chipW should exceed previous 17px cap on tall cells: got ${layoutBig.chipW}`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('chipW grows with cell width when not height-limited', async () => {
|
|
249
|
+
// Wide+tall cell with a small duplex (4 chips per strand). Width budget
|
|
250
|
+
// is generous, so chipW should be governed by the height cap.
|
|
251
|
+
const helm = 'RNA1{m(A)p.m(C)p.m(G)p.m(U)}|RNA2{m(U)p.m(G)p.m(C)p.m(A)}$$$$';
|
|
252
|
+
const m = parseHelmDuplex(helm);
|
|
253
|
+
const tallWide = computeLayout(2000, 140, m);
|
|
254
|
+
// Should fit easily; chipW capped by height budget. (cellH - 2*V_PAD)
|
|
255
|
+
// / heightFactor / aspect.
|
|
256
|
+
const heightCap = (140 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
|
|
257
|
+
expect(Math.abs(tallWide.chipW - heightCap) < 0.5, true,
|
|
258
|
+
`tall+wide cell: chipW should ride the height cap (got ${tallWide.chipW}, expected ~${heightCap.toFixed(1)})`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('top apex and bottom apex sit at least 10px from cell edges', async () => {
|
|
262
|
+
// V_PAD=10 guarantees vertical breathing room so the apex tips never
|
|
263
|
+
// touch the cell border.
|
|
264
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
265
|
+
for (const [w, h] of [[600, 70], [1000, 120], [1500, 200]]) {
|
|
266
|
+
const layout = computeLayout(w, h, m);
|
|
267
|
+
if (layout.textOnlyFallback) continue;
|
|
268
|
+
const topApexEdge = layout.senseY - layout.apexH;
|
|
269
|
+
const bottomApexEdge = layout.antiY >= 0 ? layout.antiY + layout.chipH + layout.apexH : -1;
|
|
270
|
+
expect(topApexEdge >= 10 - 0.01, true,
|
|
271
|
+
`top apex too close to top at ${w}×${h}: ${topApexEdge}`);
|
|
272
|
+
if (bottomApexEdge > 0)
|
|
273
|
+
expect(h - bottomApexEdge >= 10 - 0.01, true,
|
|
274
|
+
`bottom apex too close to bottom at ${w}×${h}: cellH-edge=${h - bottomApexEdge}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('multi-char base ellipsizes — chipW does NOT shrink for everyone else', async () => {
|
|
279
|
+
// Tight cell with one long custom base on sense. Show-everything priority
|
|
280
|
+
// means: keep chipW as big as uniform-fit allows; ellipsize the long
|
|
281
|
+
// multi-char chip rather than shrinking every chip on the row.
|
|
282
|
+
const canonical = parseHelmDuplex(
|
|
283
|
+
'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
|
|
284
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
|
|
285
|
+
const multichar = parseHelmDuplex(
|
|
286
|
+
'RNA1{m([cpm6A])p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
|
|
287
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
|
|
288
|
+
const cellW = 300; const cellH = 50;
|
|
289
|
+
const lCanon = computeLayout(cellW, cellH, canonical);
|
|
290
|
+
const lMulti = computeLayout(cellW, cellH, multichar);
|
|
291
|
+
// Sizing is governed by uniform-fit, which depends only on chip COUNT
|
|
292
|
+
// and conjugate widths — not on whether a base is single- or multi-char.
|
|
293
|
+
// So a multi-char base must not pull chipW down.
|
|
294
|
+
expect(Math.abs(lMulti.chipW - lCanon.chipW) < 0.5, true,
|
|
295
|
+
`multi-char chipW=${lMulti.chipW.toFixed(2)} drifted from canonical chipW=${lCanon.chipW.toFixed(2)}; ` +
|
|
296
|
+
`sizing should be based on uniform fit, not widened`);
|
|
297
|
+
// Every monomer still draws (just the multi-char one shows an ellipsized label).
|
|
298
|
+
expect(lMulti.senseChips.length, multichar.sense.monomers.length);
|
|
299
|
+
expect(lMulti.antiChips.length, multichar.antisense!.monomers.length);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('leading conjugate does NOT cause chipW to stall on roomy cells', async () => {
|
|
303
|
+
// Regression: `fitsInBudget` used to double-count the leading conjugate
|
|
304
|
+
// width (added `leadW` to the running total AND `widths[0]` already
|
|
305
|
+
// contained the pill width), so the binary search bailed early on cells
|
|
306
|
+
// where the conjugate strand had enough room. Net effect was the layout
|
|
307
|
+
// chose a chipW much smaller than what the cell could actually hold.
|
|
308
|
+
// This is the exact HELM the user reported, on a wide+tall cell.
|
|
309
|
+
const helm =
|
|
310
|
+
'RNA1{[Chol].m(G)[sp].[fl2r](A)[sp].m(C)p.[fl2r](U)p.m(G)p.[fl2r](A)p.m(A)p.[fl2r](U)p.m(A)p.' +
|
|
311
|
+
'[fl2r](U)p.m(A)p.[fl2r](A)p.m(A)p.[fl2r](C)p.m(U)p.[fl2r](U)p.m(G)[sp].[fl2r](U)[sp].m(G)[L3]}|' +
|
|
312
|
+
'RNA2{m(C)[sp].[fl2r]([br8A])[sp].m(C)p.[fl2r](A)p.m(A)p.[fl2r](G)p.m(U)p.[fl2r](U)p.m(U)p.' +
|
|
313
|
+
'[fl2r](A)p.m(U)p.[fl2r](A)p.m(U)p.[fl2r]([cneT])p.m(C)p.[fl2r](A)p.m(G)[sp].' +
|
|
314
|
+
'[fl2r]([c7io7A])[sp].m(C)}$$$$';
|
|
315
|
+
const m = parseHelmDuplex(helm);
|
|
316
|
+
const layoutNoConj = computeLayout(1000, 120, parseHelmDuplex(
|
|
317
|
+
'RNA1{m(G)p.m(A)p.m(C)p.m(U)p.m(G)p.m(A)p.m(A)p.m(U)p.m(A)p.m(U)p.m(A)p.m(A)p.m(A)p.m(C)p.m(U)p.m(U)p.m(G)p.m(U)p.m(G)}|' +
|
|
318
|
+
'RNA2{m(C)p.m(A)p.m(C)p.m(A)p.m(A)p.m(G)p.m(U)p.m(U)p.m(U)p.m(A)p.m(U)p.m(A)p.m(U)p.m(U)p.m(C)p.m(A)p.m(G)p.m(U)p.m(C)}$$$$'));
|
|
319
|
+
const layoutWithConj = computeLayout(1000, 120, m);
|
|
320
|
+
// With a 1000×120 cell, the height cap allows chipW ~28. The version
|
|
321
|
+
// without conjugates rides that cap; the version with [Chol]/[L3] +
|
|
322
|
+
// multi-char bases should pick a chipW that's a meaningful fraction
|
|
323
|
+
// (≥ 60%) of that — not collapse back to single digits.
|
|
324
|
+
expect(layoutWithConj.chipW >= layoutNoConj.chipW * 0.6, true,
|
|
325
|
+
`with-conjugate chipW=${layoutWithConj.chipW.toFixed(2)} dropped too far ` +
|
|
326
|
+
`below no-conjugate chipW=${layoutNoConj.chipW.toFixed(2)} on the same roomy cell`);
|
|
327
|
+
// And every monomer must place.
|
|
328
|
+
expect(layoutWithConj.senseChips.length, m.sense.monomers.length);
|
|
329
|
+
expect(layoutWithConj.antiChips.length, m.antisense!.monomers.length);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('leading conjugate reduces chipW but no monomer is dropped', async () => {
|
|
333
|
+
// With a Cholesterol conjugate at sense 5', the conjugate pill consumes
|
|
334
|
+
// a chunk of horizontal budget. chipW shrinks compared to the same data
|
|
335
|
+
// without the conjugate — but all chips still place.
|
|
336
|
+
const noConj = parseHelmDuplex(
|
|
337
|
+
'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
|
|
338
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
|
|
339
|
+
const withConj = parseHelmDuplex(
|
|
340
|
+
'RNA1{[Chol].m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
|
|
341
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
|
|
342
|
+
const cellW = 350; const cellH = 70;
|
|
343
|
+
const lNo = computeLayout(cellW, cellH, noConj);
|
|
344
|
+
const lW = computeLayout(cellW, cellH, withConj);
|
|
345
|
+
expect(lW.chipW <= lNo.chipW + 0.01, true,
|
|
346
|
+
`with-conjugate chipW=${lW.chipW} must be ≤ no-conjugate chipW=${lNo.chipW}`);
|
|
347
|
+
// Every monomer (incl. the conjugate pill) is placed.
|
|
348
|
+
expect(lW.senseChips.length, withConj.sense.monomers.length);
|
|
349
|
+
expect(lW.antiChips.length, withConj.antisense!.monomers.length);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('tight cell falls back to uniform widths with ellipsis (no text-only)', async () => {
|
|
353
|
+
// A duplex with a long base name on a moderately tight cell: widened
|
|
354
|
+
// would overflow even at MIN_CHIP_W, but uniform widths (with the
|
|
355
|
+
// multi-char chip ellipsized) still fit.
|
|
356
|
+
const m = parseHelmDuplex(
|
|
357
|
+
'RNA1{m([cpm6A])p.m([5Br-dC])p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)}|' +
|
|
358
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)}$$$$');
|
|
359
|
+
const layout = computeLayout(220, 50, m);
|
|
360
|
+
// Not text-only — everything still draws as chips, just with truncated labels.
|
|
361
|
+
expect(layout.textOnlyFallback, false,
|
|
362
|
+
'tight-but-survivable cell should not collapse to text-only');
|
|
363
|
+
// All chips placed (no chip dropped).
|
|
364
|
+
expect(layout.senseChips.length, m.sense.monomers.length);
|
|
365
|
+
expect(layout.antiChips.length, m.antisense!.monomers.length);
|
|
218
366
|
});
|
|
219
367
|
|
|
220
368
|
test('compact cell — both strands still fit in 28px row', async () => {
|
|
@@ -275,6 +423,71 @@ category('OligoRenderer: layout', () => {
|
|
|
275
423
|
expect(conj.monomer.kind, 'conjugate');
|
|
276
424
|
expect(conj.w >= layout.chipW, true, 'conjugate must be at least chip-wide');
|
|
277
425
|
});
|
|
426
|
+
|
|
427
|
+
test('reversed-antisense linkage owner is the lower-indexed pair member', async () => {
|
|
428
|
+
// The `phosphate` field on a nucleotide always means "linkage immediately
|
|
429
|
+
// AFTER this monomer in 5'→3' data order" — so it lives on the lower-indexed
|
|
430
|
+
// end of the bond. When antisense is displayed reversed, the gap to the
|
|
431
|
+
// right of display index i pairs data positions (N-1-i, N-2-i); the owner
|
|
432
|
+
// must be the lower one, i.e. `monomers[i+1]` in the reversed array, not `m`.
|
|
433
|
+
//
|
|
434
|
+
// Small fixture: antisense `r(A)[sp].r(C)p.r(G)` →
|
|
435
|
+
// data 0 → A, phos=sp (linkage 0↔1, sp)
|
|
436
|
+
// data 1 → C, phos=p (linkage 1↔2, p)
|
|
437
|
+
// data 2 → G, phos='' (terminal)
|
|
438
|
+
// Reversed display: G, C, A.
|
|
439
|
+
// antiLinks[0] = gap right of display 0 → between data 2 and 1 → p, owner 1
|
|
440
|
+
// antiLinks[1] = gap right of display 1 → between data 1 and 0 → sp, owner 0
|
|
441
|
+
const helm = 'RNA1{r(A)p.r(C)p.r(G)}|RNA2{r(A)[sp].r(C)p.r(G)}$$$$';
|
|
442
|
+
const m = parseHelmDuplex(helm);
|
|
443
|
+
const layout = computeLayout(600, 70, m);
|
|
444
|
+
expect(layout.antiReversed, true);
|
|
445
|
+
expect(layout.antiLinks.length, 2);
|
|
446
|
+
expect(layout.antiLinks[0].ownerOrigIdx, 1);
|
|
447
|
+
expect(layout.antiLinks[0].phosphateSymbol, 'p');
|
|
448
|
+
expect(layout.antiLinks[1].ownerOrigIdx, 0);
|
|
449
|
+
expect(layout.antiLinks[1].phosphateSymbol, 'sp');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('reversed-antisense draws ALL linkages including the leftmost-data sp', async () => {
|
|
453
|
+
// Regression: pre-fix, reversed-strand placement read `m.phosphate` for
|
|
454
|
+
// the gap to the right of display i, but that field belongs to the bond
|
|
455
|
+
// on the OTHER side of `m`. The net effect was a one-index shift across
|
|
456
|
+
// the row plus a dropped link at the terminal display position — so the
|
|
457
|
+
// 4 sp linkages on this antisense ended up as 3, in the wrong gaps.
|
|
458
|
+
const helm =
|
|
459
|
+
'RNA1{m(C)[sp].m(A)[sp].m(U)p.m(G)p.m(G)p.m(U)p.m(U)p.m(G)p.m(A)p.m(A)p.' +
|
|
460
|
+
'm(C)p.m(A)p.m(U)p.m(G)p.m(A)p.m(G)p.m(C)[sp].m(A)[sp].m(A)[L3]}|' +
|
|
461
|
+
'RNA2{m(U)[sp].m(U)[sp].m(G)p.m(C)p.m(U)p.m(C)p.m(A)p.m(U)p.m(G)p.m(U)p.' +
|
|
462
|
+
'm(U)p.m(C)p.m(A)p.m(A)p.m(C)p.m(C)p.m(A)[sp].m(U)[sp].m(G)}$$$$';
|
|
463
|
+
const m = parseHelmDuplex(helm);
|
|
464
|
+
const layout = computeLayout(1200, 90, m);
|
|
465
|
+
expect(layout.antiReversed, true);
|
|
466
|
+
// Antisense has 19 nucleotides → 18 inter-nucleotide gaps, all drawn.
|
|
467
|
+
expect(layout.antiLinks.length, 18,
|
|
468
|
+
`expected 18 antisense linkages, got ${layout.antiLinks.length}`);
|
|
469
|
+
// 4 of them must be `sp`. With reversal, the sp at data 0↔1 maps to the
|
|
470
|
+
// RIGHTMOST display gap and the sp at data 17↔18 maps to the LEFTMOST —
|
|
471
|
+
// so sp owners, in display order, are 17, 16, 1, 0.
|
|
472
|
+
const spOwners = layout.antiLinks
|
|
473
|
+
.filter((l) => l.phosphateSymbol === 'sp')
|
|
474
|
+
.map((l) => l.ownerOrigIdx);
|
|
475
|
+
expect(spOwners.length, 4,
|
|
476
|
+
`expected 4 sp linkages on antisense, got ${spOwners.length}`);
|
|
477
|
+
expect(spOwners.join(','), '17,16,1,0',
|
|
478
|
+
`sp owners (display order) must be 17,16,1,0; got ${spOwners.join(',')}`);
|
|
479
|
+
|
|
480
|
+
// And sense — 19 nucleotides + L3 conjugate. 18 nucleotide-to-nucleotide
|
|
481
|
+
// gaps; the bond into the L3 conjugate is not a phosphate, so it isn't
|
|
482
|
+
// pushed as a link. 4 of those 18 are sp (data owners 0, 1, 16, 17).
|
|
483
|
+
expect(layout.senseLinks.length, 18,
|
|
484
|
+
`expected 18 sense linkages, got ${layout.senseLinks.length}`);
|
|
485
|
+
const senseSpOwners = layout.senseLinks
|
|
486
|
+
.filter((l) => l.phosphateSymbol === 'sp')
|
|
487
|
+
.map((l) => l.ownerOrigIdx)
|
|
488
|
+
.sort((a, b) => a - b);
|
|
489
|
+
expect(senseSpOwners.join(','), '0,1,16,17');
|
|
490
|
+
});
|
|
278
491
|
});
|
|
279
492
|
|
|
280
493
|
category('OligoRenderer: hit testing', () => {
|
|
@@ -288,18 +501,12 @@ category('OligoRenderer: hit testing', () => {
|
|
|
288
501
|
expect(hit!.strand, 'sense');
|
|
289
502
|
expect(hit!.position, 0);
|
|
290
503
|
|
|
291
|
-
// Gap
|
|
292
|
-
|
|
293
|
-
|
|
504
|
+
// Gap between chips at chip-row-Y should always miss now — PS linkage
|
|
505
|
+
// markers live in the apex zone above (sense) / below (antisense) the
|
|
506
|
+
// chip row, not in the inter-chip gap at chip-row-Y.
|
|
294
507
|
const gapX = first.x + first.w + layout.chipGap * 0.1;
|
|
295
508
|
const miss = hitTest(gapX, layout.senseY + layout.chipH / 2, m, layout);
|
|
296
|
-
|
|
297
|
-
// Should hit the PS linkage marker
|
|
298
|
-
expect(miss !== null, true);
|
|
299
|
-
expect(miss!.linkage !== undefined, true);
|
|
300
|
-
} else {
|
|
301
|
-
expect(miss, null);
|
|
302
|
-
}
|
|
509
|
+
expect(miss, null);
|
|
303
510
|
});
|
|
304
511
|
|
|
305
512
|
test('antisense hit returns original position despite reversed display', async () => {
|
|
@@ -314,15 +521,18 @@ category('OligoRenderer: hit testing', () => {
|
|
|
314
521
|
'leftmost AS chip in pair-aligned display = last monomer in data');
|
|
315
522
|
});
|
|
316
523
|
|
|
317
|
-
test('hovering
|
|
318
|
-
//
|
|
524
|
+
test('hovering the apex above a PS linkage returns the linkage', async () => {
|
|
525
|
+
// PS linkage between sense pos 0 and 1. The apex sits above the sense
|
|
526
|
+
// chip row (since sense's decoration side is "top"), peak at senseY-apexH.
|
|
319
527
|
const helm = 'RNA1{m(G)[sp].m(A)p.m(C)p}$$$$';
|
|
320
528
|
const m = parseHelmDuplex(helm);
|
|
321
529
|
const layout = computeLayout(600, 70, m);
|
|
322
530
|
const c0 = layout.senseChips[0];
|
|
323
531
|
const c1 = layout.senseChips[1];
|
|
324
532
|
const midX = (c0.x + c0.w + c1.x) / 2;
|
|
325
|
-
|
|
533
|
+
// Apex zone is [senseY - apexH, senseY]; aim mid-zone.
|
|
534
|
+
const apexY = layout.senseY - layout.apexH / 2;
|
|
535
|
+
const hit = hitTest(midX, apexY, m, layout);
|
|
326
536
|
expect(hit !== null, true);
|
|
327
537
|
expect(hit!.linkage !== undefined, true);
|
|
328
538
|
expect(hit!.linkage!.phosphateSymbol, 'sp');
|