@kerusiweb/core 2.2.0 → 3.0.0

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 (77) hide show
  1. package/LICENSE +22 -22
  2. package/README.md +70 -68
  3. package/dist/kerusi/index.d.ts +4 -3
  4. package/dist/kerusi/index.d.ts.map +1 -1
  5. package/dist/kerusi/index.js +4 -3
  6. package/dist/kerusi/index.js.map +1 -1
  7. package/dist/kerusi/kerusi-geometry.d.ts +47 -0
  8. package/dist/kerusi/kerusi-geometry.d.ts.map +1 -0
  9. package/dist/kerusi/kerusi-geometry.js +111 -0
  10. package/dist/kerusi/kerusi-geometry.js.map +1 -0
  11. package/dist/kerusi/kerusi-map.model.d.ts +121 -68
  12. package/dist/kerusi/kerusi-map.model.d.ts.map +1 -1
  13. package/dist/kerusi/kerusi-map.model.js +6 -2
  14. package/dist/kerusi/kerusi-map.model.js.map +1 -1
  15. package/dist/kerusi/kerusi-rows.d.ts +2 -2
  16. package/dist/kerusi/kerusi-rows.js +2 -2
  17. package/dist/kerusi/kerusi-validator.d.ts +18 -9
  18. package/dist/kerusi/kerusi-validator.d.ts.map +1 -1
  19. package/dist/kerusi/kerusi-validator.js +301 -122
  20. package/dist/kerusi/kerusi-validator.js.map +1 -1
  21. package/dist/render/build-render-model.d.ts +8 -4
  22. package/dist/render/build-render-model.d.ts.map +1 -1
  23. package/dist/render/build-render-model.js +147 -45
  24. package/dist/render/build-render-model.js.map +1 -1
  25. package/dist/render/index.d.ts +1 -1
  26. package/dist/render/index.d.ts.map +1 -1
  27. package/dist/render/index.js +1 -1
  28. package/dist/render/index.js.map +1 -1
  29. package/dist/render/map-layout.d.ts +92 -0
  30. package/dist/render/map-layout.d.ts.map +1 -0
  31. package/dist/render/map-layout.js +93 -0
  32. package/dist/render/map-layout.js.map +1 -0
  33. package/dist/render/navigation-order.d.ts +20 -10
  34. package/dist/render/navigation-order.d.ts.map +1 -1
  35. package/dist/render/navigation-order.js +72 -57
  36. package/dist/render/navigation-order.js.map +1 -1
  37. package/dist/render/render-model.d.ts +48 -26
  38. package/dist/render/render-model.d.ts.map +1 -1
  39. package/dist/render/seat-shapes.d.ts +36 -3
  40. package/dist/render/seat-shapes.d.ts.map +1 -1
  41. package/dist/render/seat-shapes.js +50 -3
  42. package/dist/render/seat-shapes.js.map +1 -1
  43. package/dist/view/index.d.ts +2 -0
  44. package/dist/view/index.d.ts.map +1 -1
  45. package/dist/view/index.js +2 -0
  46. package/dist/view/index.js.map +1 -1
  47. package/dist/view/kerusi-seat-style.d.ts +58 -0
  48. package/dist/view/kerusi-seat-style.d.ts.map +1 -0
  49. package/dist/view/kerusi-seat-style.js +32 -0
  50. package/dist/view/kerusi-seat-style.js.map +1 -0
  51. package/dist/view/kerusi-seatmap-colors.d.ts +41 -5
  52. package/dist/view/kerusi-seatmap-colors.d.ts.map +1 -1
  53. package/dist/view/kerusi-seatmap-colors.js +67 -8
  54. package/dist/view/kerusi-seatmap-colors.js.map +1 -1
  55. package/dist/view/rtl.d.ts +1 -1
  56. package/dist/view/rtl.js +1 -1
  57. package/dist/view/seat-aria.d.ts +6 -0
  58. package/dist/view/seat-aria.d.ts.map +1 -1
  59. package/dist/view/seat-aria.js +8 -0
  60. package/dist/view/seat-aria.js.map +1 -1
  61. package/dist/view/seat-glyph.d.ts +137 -0
  62. package/dist/view/seat-glyph.d.ts.map +1 -0
  63. package/dist/view/seat-glyph.js +130 -0
  64. package/dist/view/seat-glyph.js.map +1 -0
  65. package/dist/view/viewport.d.ts +35 -7
  66. package/dist/view/viewport.d.ts.map +1 -1
  67. package/dist/view/viewport.js +56 -7
  68. package/dist/view/viewport.js.map +1 -1
  69. package/package.json +42 -42
  70. package/dist/kerusi/kerusi-layout-mode.d.ts +0 -58
  71. package/dist/kerusi/kerusi-layout-mode.d.ts.map +0 -1
  72. package/dist/kerusi/kerusi-layout-mode.js +0 -117
  73. package/dist/kerusi/kerusi-layout-mode.js.map +0 -1
  74. package/dist/render/section-layout.d.ts +0 -66
  75. package/dist/render/section-layout.d.ts.map +0 -1
  76. package/dist/render/section-layout.js +0 -186
  77. package/dist/render/section-layout.js.map +0 -1
@@ -1,6 +1,5 @@
1
1
  import { resolveSeatPrice } from './kerusi-price.js';
2
- import { checkSectionLayout, hasAnyCoordinate, hasCol, hasXY, resolveSectionLayoutMode, } from './kerusi-layout-mode.js';
3
- import { rowOrderIndexes } from './kerusi-rows.js';
2
+ import { footprintOf, footprintsOverlap, seatSizeOf } from './kerusi-geometry.js';
4
3
  import { error, errorsOf, warning } from './kerusi-violation.js';
5
4
  export { errorsOf } from './kerusi-violation.js';
6
5
  /** The spec major version this library implements. */
@@ -184,23 +183,32 @@ export function validateDocumentSet(docs) {
184
183
  * unchanged when valid, so it can be used inline.
185
184
  *
186
185
  * Enforced rules:
187
- * - Required members present: `kerusi`, `id`, `legend`, `sections` (§4), and
188
- * `kerusi` declares a supported major version.
189
- * - Every seat has `col`, or `x`+`y`, or both — never neither (§4.3.1) — and
190
- * conforms to its section's declared or inferred `layout` mode (§4.5).
191
- * - Section, seat and element ids are unique (§4.1, §4.3, §4.4).
186
+ * - Required members present: `kerusi`, `id`, `canvas`, `seatSize`, `legend`,
187
+ * `sections` (§4), and `kerusi` declares a supported major version.
188
+ * - The canvas and every seat size are positive (§4.5).
189
+ * - Every seat and element has `x` and `y` on the canvas (§4.3.1, §4.4.1), and
190
+ * an element's `width`/`height`, when given, are positive.
191
+ * - A seat's `col` is a positive integer, only with a `row`, and no two seats
192
+ * in a section share a row and col (§4.3.5).
193
+ * - Section, seat, element, seat type and price tier ids are unique across the
194
+ * map, and row ids within their section (§4.3, §4.6).
192
195
  * - Every `Seat.type` resolves to a `SeatType.id` in `legend` (§4.6).
193
196
  * - Every `Seat.priceTier` resolves to a `PriceTier.id` (§4.6).
194
197
  * - Every `Seat.row` resolves to a `RowMeta.id` when the section declares
195
198
  * `rows`; opaque free-text otherwise (§4.6).
196
199
  * - Every `Seat.companions[]` entry resolves to another seat in the SAME
197
200
  * section, and the relationship is symmetric (§4.6).
198
- * - Every `Element` has the required `id` and `kind` (§4.4), is positioned in
199
- * its section's own layout mode, and — in a grid section — carries integer
200
- * column and row spans that stay inside the section's rows (§4.4.1, §4.6).
201
+ * - Every row shape is exactly one well-formed line, arc or circle (§4.2.3).
202
+ * - Every `Element` has the required `id` and `kind` (§4.4).
201
203
  * - All resolved currencies across the map are identical (§4.9).
202
204
  *
203
- * Advisory findings never throw; use {@link checkKerusiMap} to see them.
205
+ * Members this library does not recognize are ignored (§7), including the
206
+ * members rev 15 removed from sections and elements; a rev 14 map is still
207
+ * rejected, for lacking `canvas` and `seatSize`.
208
+ *
209
+ * Advisory findings never throw: overlapping seats (§4.3.1), seats outside
210
+ * their section's `bounds` (§4.1), an unknown `transferArmrest`. Use
211
+ * {@link checkKerusiMap} to see them.
204
212
  */
205
213
  export function validateKerusiMap(map) {
206
214
  throwFirstError(collectMapViolations(map));
@@ -239,9 +247,10 @@ function throwFirstError(violations) {
239
247
  }
240
248
  // --- Collectors -------------------------------------------------------------
241
249
  /**
242
- * Walks a map and returns every violation in document order. The order matters:
243
- * `validateKerusiMap` throws the first error, so it must report the same rule
244
- * the pre-1.0 short-circuiting validator did for any given document.
250
+ * Walks a map and returns every violation in document order, errors first and
251
+ * the §4.1/§4.3.1 warnings after them. The order matters: `validateKerusiMap`
252
+ * throws the first error, so the same document must always report the same
253
+ * rule first.
245
254
  */
246
255
  function collectMapViolations(map) {
247
256
  if (!map || typeof map !== 'object') {
@@ -257,6 +266,7 @@ function collectMapViolations(map) {
257
266
  if (!map.id) {
258
267
  violations.push(error('map-id', 'KerusiMap is missing the required "id" member.'));
259
268
  }
269
+ violations.push(...collectCanvasViolations(map));
260
270
  if (!Array.isArray(map.legend)) {
261
271
  violations.push(error('map-legend', 'KerusiMap is missing the required "legend" array.', { id: map.id }));
262
272
  }
@@ -268,50 +278,57 @@ function collectMapViolations(map) {
268
278
  if (!Array.isArray(map.legend) || !Array.isArray(map.sections)) {
269
279
  return violations;
270
280
  }
271
- const seatTypeIds = new Set(map.legend.map((t) => t.id));
272
- const priceTierIds = new Set((map.priceTiers ?? []).map((t) => t.id));
273
- const currencies = new Set();
281
+ const ctx = {
282
+ map,
283
+ canvas: isPositive(map.canvas?.width) && isPositive(map.canvas?.height) ? map.canvas : undefined,
284
+ seatTypeIds: new Set(map.legend.map((t) => t.id)),
285
+ priceTierIds: new Set((map.priceTiers ?? []).map((t) => t.id)),
286
+ currencies: new Set(),
287
+ };
274
288
  // Tier-level currencies count toward the single-currency rule regardless of
275
289
  // whether a seat references the tier.
276
290
  for (const tier of map.priceTiers ?? []) {
277
291
  if (tier.price?.currency) {
278
- currencies.add(tier.price.currency);
292
+ ctx.currencies.add(tier.price.currency);
279
293
  }
280
294
  }
281
- const seenSectionIds = new Set();
282
- const seenSeatIds = new Set();
283
- const duplicateSections = [];
284
- const duplicateSeats = [];
285
295
  map.sections.forEach((section, sectionIndex) => {
286
- const path = `sections[${sectionIndex}]`;
287
- violations.push(...collectSectionViolations(section, map, seatTypeIds, priceTierIds, currencies, path));
288
- if (seenSectionIds.has(section?.id)) {
289
- duplicateSections.push(section.id);
290
- }
291
- seenSectionIds.add(section?.id);
292
- for (const seat of section?.seats ?? []) {
293
- if (seenSeatIds.has(seat.id)) {
294
- duplicateSeats.push(seat.id);
295
- }
296
- seenSeatIds.add(seat.id);
297
- }
296
+ violations.push(...collectSectionViolations(section, ctx, `sections[${sectionIndex}]`));
298
297
  });
299
- for (const id of duplicateSections) {
300
- violations.push(error('section-id-duplicate', `Section id "${id}" is declared more than once (§4.1).`, {
301
- id,
298
+ // §4.6 identifier uniqueness. Seats, sections and elements are unique across
299
+ // the whole map; rows only within their section, checked there.
300
+ const sections = map.sections.filter((s) => isObject(s));
301
+ violations.push(...duplicateIds(sections, 'section-id-duplicate', (id) => `Section id "${id}" is declared more than once (§4.1).`), ...duplicateIds(sections.flatMap((s) => (Array.isArray(s.seats) ? s.seats : [])), 'seat-id-duplicate', (id) => `Seat id "${id}" is declared more than once; seat ids must be globally unique ` +
302
+ 'within a KerusiMap (§4.3).'), ...duplicateIds(sections.flatMap((s) => (Array.isArray(s.elements) ? s.elements : [])), 'element-id-duplicate', (id) => `Element id "${id}" is declared more than once; element ids must be unique ` +
303
+ 'across the whole KerusiMap (§4.6).'), ...duplicateIds(map.legend, 'seat-type-id-duplicate', (id) => `Seat type id "${id}" is declared more than once in the legend (§4.6).`), ...duplicateIds(map.priceTiers ?? [], 'price-tier-id-duplicate', (id) => `Price tier id "${id}" is declared more than once (§4.6).`));
304
+ if (ctx.currencies.size > 1) {
305
+ violations.push(error('single-currency', `KerusiMap mixes currencies (${[...ctx.currencies].sort().join(', ')}); ` +
306
+ 'exactly one currency must apply to the whole map (§4.9).', { id: map.id }));
307
+ }
308
+ violations.push(...collectSeatOverlapWarnings(map), ...collectSectionBoundsWarnings(map));
309
+ return violations;
310
+ }
311
+ /** §4.5: the canvas and the map's seat size, both required and positive. */
312
+ function collectCanvasViolations(map) {
313
+ const violations = [];
314
+ const canvas = map.canvas;
315
+ if (!isObject(canvas)) {
316
+ violations.push(error('map-canvas', 'KerusiMap is missing the required "canvas" object (§4.5).', {
317
+ id: map.id,
302
318
  }));
303
319
  }
304
- for (const id of duplicateSeats) {
305
- violations.push(error('seat-id-duplicate', `Seat id "${id}" is declared more than once; seat ids must be globally unique ` +
306
- 'within a KerusiMap (§4.3).', { id }));
320
+ else if (!isPositive(canvas['width']) || !isPositive(canvas['height'])) {
321
+ violations.push(error('map-canvas-size', `KerusiMap canvas is ${canvas['width']} × ${canvas['height']}; both dimensions ` +
322
+ 'must be positive numbers (§4.5).', { id: map.id, path: 'canvas' }));
307
323
  }
308
- if (currencies.size > 1) {
309
- violations.push(error('single-currency', `KerusiMap mixes currencies (${[...currencies].sort().join(', ')}); ` +
310
- 'exactly one currency must apply to the whole map (§4.9).', { id: map.id }));
324
+ if (!isPositive(map.seatSize)) {
325
+ violations.push(error('map-seat-size', map.seatSize === undefined
326
+ ? 'KerusiMap is missing the required "seatSize" (§4.5).'
327
+ : `KerusiMap "seatSize" is ${map.seatSize}; it must be a positive number (§4.5).`, { id: map.id, path: 'seatSize' }));
311
328
  }
312
329
  return violations;
313
330
  }
314
- function collectSectionViolations(section, map, seatTypeIds, priceTierIds, currencies, path) {
331
+ function collectSectionViolations(section, ctx, path) {
315
332
  if (!section || typeof section !== 'object') {
316
333
  return [error('section-shape', 'A section is missing or not an object.', { path })];
317
334
  }
@@ -319,6 +336,10 @@ function collectSectionViolations(section, map, seatTypeIds, priceTierIds, curre
319
336
  if (!section.id) {
320
337
  violations.push(error('section-id', 'A section is missing the required "id".', { path }));
321
338
  }
339
+ if (section.seatSize !== undefined && !isPositive(section.seatSize)) {
340
+ violations.push(error('section-seat-size', `Section "${section.id}" has "seatSize": ${section.seatSize}; it must be a ` +
341
+ 'positive number (§4.5).', { id: section.id, path: `${path}.seatSize` }));
342
+ }
322
343
  if (!Array.isArray(section.seats)) {
323
344
  violations.push(error('section-seats', `Section "${section.id}" is missing the required "seats" array.`, {
324
345
  id: section.id,
@@ -326,24 +347,43 @@ function collectSectionViolations(section, map, seatTypeIds, priceTierIds, curre
326
347
  }));
327
348
  return violations;
328
349
  }
350
+ violations.push(...collectRowViolations(section, path));
329
351
  const rowIds = section.rows ? new Set(section.rows.map((r) => r.id)) : null;
330
352
  const seatIds = new Set(section.seats.map((s) => s.id));
331
353
  const companionsBySeat = new Map();
354
+ const seatAtRowCol = new Map();
332
355
  section.seats.forEach((seat, seatIndex) => {
333
356
  const seatPath = `${path}.seats[${seatIndex}]`;
334
- // §4.3.1 positioning. Checked before the §4.5 mode rules so a seat with no
335
- // position at all gets the clearer message rather than a mode complaint.
336
- if (!hasCol(seat) && !hasXY(seat)) {
337
- violations.push(error('seat-position', `Seat "${seat.id}" has neither "col" nor "x"+"y"; a seat must specify ` +
338
- 'at least one positioning method (§4.3.1).', { id: seat.id, path: seatPath }));
357
+ // §4.3.1 position: required canvas coordinates, on the canvas.
358
+ violations.push(...collectPositionViolations(seat, 'seat', ctx, seatPath));
359
+ // §4.3.5 adjacency: `col` is a positive integer ordinal within `row`.
360
+ if (seat.col !== undefined) {
361
+ if (seat.row === undefined) {
362
+ violations.push(error('seat-col-without-row', `Seat "${seat.id}" has "col" but no "row"; a col is an ordinal within a row ` +
363
+ 'and means nothing without one (§4.3.5).', { id: seat.id, path: seatPath }));
364
+ }
365
+ if (!Number.isInteger(seat.col) || seat.col < 1) {
366
+ violations.push(error('seat-col-invalid', `Seat "${seat.id}" has "col": ${seat.col}; a col is a positive integer (§4.3.5).`, { id: seat.id, path: seatPath }));
367
+ }
368
+ else if (seat.row !== undefined) {
369
+ const key = JSON.stringify([seat.row, seat.col]);
370
+ const holder = seatAtRowCol.get(key);
371
+ if (holder !== undefined) {
372
+ violations.push(error('seat-row-col-duplicate', `Seat "${seat.id}" is at row "${seat.row}", col ${seat.col}, which seat ` +
373
+ `"${holder}" already holds in section "${section.id}" (§4.3.5).`, { id: seat.id, path: seatPath }));
374
+ }
375
+ else {
376
+ seatAtRowCol.set(key, seat.id);
377
+ }
378
+ }
339
379
  }
340
380
  // §4.6 type reference.
341
- if (!seatTypeIds.has(seat.type)) {
381
+ if (!ctx.seatTypeIds.has(seat.type)) {
342
382
  violations.push(error('seat-type-reference', `Seat "${seat.id}" references unknown type "${seat.type}"; not found in ` +
343
383
  'the map legend (§4.6).', { id: seat.id, path: seatPath }));
344
384
  }
345
385
  // §4.6 priceTier reference.
346
- if (seat.priceTier !== undefined && !priceTierIds.has(seat.priceTier)) {
386
+ if (seat.priceTier !== undefined && !ctx.priceTierIds.has(seat.priceTier)) {
347
387
  violations.push(error('seat-pricetier-reference', `Seat "${seat.id}" references unknown priceTier "${seat.priceTier}" (§4.6).`, { id: seat.id, path: seatPath }));
348
388
  }
349
389
  // §4.6 row reference — only when the section declares a rows array.
@@ -359,9 +399,9 @@ function collectSectionViolations(section, map, seatTypeIds, priceTierIds, curre
359
399
  'left | right | both | fixed | none (§4.3.4).', { id: seat.id, path: seatPath }));
360
400
  }
361
401
  // Collect the resolved currency for the single-currency rule (§4.9).
362
- const price = resolveSeatPrice(seat, map);
402
+ const price = resolveSeatPrice(seat, ctx.map);
363
403
  if (price?.currency) {
364
- currencies.add(price.currency);
404
+ ctx.currencies.add(price.currency);
365
405
  }
366
406
  companionsBySeat.set(seat.id, new Set(seat.companions ?? []));
367
407
  });
@@ -380,18 +420,113 @@ function collectSectionViolations(section, map, seatTypeIds, priceTierIds, curre
380
420
  }
381
421
  }
382
422
  }
383
- // §4.5 layout modes — a strict, validated constraint at v1.0.
384
- violations.push(...checkSectionLayout(section, path));
385
423
  // §4.4 elements.
386
- violations.push(...collectElementViolations(section, path));
424
+ violations.push(...collectElementViolations(section, ctx, path));
387
425
  return violations;
388
426
  }
389
- function collectElementViolations(section, path) {
427
+ /**
428
+ * §4.3.1 and §4.4.1: a seat or element carries `x` and `y` in canvas units, on
429
+ * the canvas — 0 to `canvas.width` and 0 to `canvas.height`, edges included.
430
+ */
431
+ function collectPositionViolations(item, kind, ctx, path) {
432
+ const label = kind === 'seat' ? 'Seat' : 'Element';
433
+ const section = kind === 'seat' ? '§4.3.1' : '§4.4.1';
434
+ if (!isNum(item.x) || !isNum(item.y)) {
435
+ return [
436
+ error(`${kind}-position`, `${label} "${item.id}" is missing "x" or "y"; every ${kind} is placed on the ` +
437
+ `canvas by its centre (${section}).`, { id: item.id, path }),
438
+ ];
439
+ }
440
+ if (!ctx.canvas) {
441
+ return [];
442
+ }
443
+ const violations = [];
444
+ for (const [axis, value, limit] of [
445
+ ['x', item.x, ctx.canvas.width],
446
+ ['y', item.y, ctx.canvas.height],
447
+ ]) {
448
+ if (value < 0 || value > limit) {
449
+ violations.push(error(`${kind}-outside-canvas`, `${label} "${item.id}" has ${axis} ${value}, outside the canvas (0 to ${limit}, ` +
450
+ `the canvas ${axis === 'x' ? 'width' : 'height'}) (${section}).`, { id: item.id, path: `${path}.${axis}` }));
451
+ }
452
+ }
453
+ return violations;
454
+ }
455
+ /** §4.2: row ids unique within the section, and every row shape well formed. */
456
+ function collectRowViolations(section, path) {
457
+ if (!Array.isArray(section.rows)) {
458
+ return [];
459
+ }
460
+ const violations = duplicateIds(section.rows, 'row-id-duplicate', (id) => `Row id "${id}" is declared more than once in section "${section.id}" (§4.6).`);
461
+ section.rows.forEach((row, i) => {
462
+ if (row?.shape !== undefined) {
463
+ violations.push(...collectRowShapeViolations(row, `${path}.rows[${i}].shape`));
464
+ }
465
+ });
466
+ return violations;
467
+ }
468
+ const ROW_SHAPE_KINDS = ['line', 'arc', 'circle'];
469
+ /**
470
+ * §4.2.3: exactly one of `line`, `arc` or `circle`, with its points, a positive
471
+ * radius, `arc.sweep`, and `cols` as two positive integers `[first, last]`
472
+ * with first not exceeding last. Nothing here moves or reads a seat: a shape
473
+ * is a guide for editors, and a renderer ignores it.
474
+ */
475
+ function collectRowShapeViolations(row, path) {
476
+ const shape = row.shape;
477
+ const kinds = isObject(shape) ? ROW_SHAPE_KINDS.filter((k) => k in shape) : [];
478
+ if (!isObject(shape) || kinds.length !== 1) {
479
+ return [
480
+ error('row-shape-kind', `Row "${row.id}" has a shape that is not exactly one of line, arc or circle ` +
481
+ `(found ${kinds.length ? kinds.join(' and ') : 'none'}) (§4.2.3).`, { id: row.id, path }),
482
+ ];
483
+ }
484
+ const kind = kinds[0];
485
+ const g = shape[kind];
486
+ const problems = [];
487
+ if (!isObject(g)) {
488
+ problems.push(`"${kind}" is not an object`);
489
+ }
490
+ else {
491
+ const points = kind === 'line' ? ['from', 'to'] : ['center'];
492
+ for (const member of points) {
493
+ if (!isPoint(g[member])) {
494
+ problems.push(`"${member}" is not an [x, y] point`);
495
+ }
496
+ }
497
+ if (kind !== 'line') {
498
+ if (!isPositive(g['radius'])) {
499
+ problems.push('"radius" is not a positive number');
500
+ }
501
+ if (!isNum(g['start'])) {
502
+ problems.push('"start" is not a number');
503
+ }
504
+ }
505
+ if (kind === 'arc' && !isNum(g['sweep'])) {
506
+ problems.push('"sweep" is missing');
507
+ }
508
+ const cols = g['cols'];
509
+ if (!Array.isArray(cols) ||
510
+ cols.length !== 2 ||
511
+ !cols.every((c) => Number.isInteger(c) && c >= 1)) {
512
+ problems.push('"cols" is not two positive integers');
513
+ }
514
+ else if (cols[0] > cols[1]) {
515
+ return [
516
+ error('row-shape-cols-reversed', `Row "${row.id}" has a ${kind} shape spanning cols [${cols[0]}, ${cols[1]}]; ` +
517
+ 'the first column must not exceed the last (§4.2.3).', { id: row.id, path: `${path}.${kind}.cols` }),
518
+ ];
519
+ }
520
+ }
521
+ if (problems.length === 0) {
522
+ return [];
523
+ }
524
+ return [
525
+ error('row-shape-invalid', `Row "${row.id}" has a malformed ${kind} shape: ${problems.join('; ')} (§4.2.3).`, { id: row.id, path: `${path}.${kind}` }),
526
+ ];
527
+ }
528
+ function collectElementViolations(section, ctx, path) {
390
529
  const violations = [];
391
- const mode = resolveSectionLayoutMode(section);
392
- const rowIndexes = rowOrderIndexes(section);
393
- const declaresRows = Boolean(section.rows);
394
- const seen = new Set();
395
530
  (section.elements ?? []).forEach((element, i) => {
396
531
  const elementPath = `${path}.elements[${i}]`;
397
532
  if (!element.id) {
@@ -399,86 +534,130 @@ function collectElementViolations(section, path) {
399
534
  path: elementPath,
400
535
  }));
401
536
  }
402
- else {
403
- if (seen.has(element.id)) {
404
- violations.push(error('element-id-duplicate', `Element id "${element.id}" is declared more than once in section ` +
405
- `"${section.id}" (§4.4).`, { id: element.id, path: elementPath }));
406
- }
407
- seen.add(element.id);
408
- }
409
537
  if (!element.kind) {
410
538
  violations.push(error('element-kind', `Element "${element.id}" has no "kind" (§4.4).`, {
411
539
  id: element.id,
412
540
  path: elementPath,
413
541
  }));
414
542
  }
415
- // §4.6: an element's row resolves against the registry exactly as a seat's
416
- // does, and for the same reason — a row that does not exist places nothing.
417
- if (declaresRows && element.row !== undefined && !rowIndexes.has(element.row)) {
418
- violations.push(error('element-row-unresolved', `Element "${element.id}" references unknown row "${element.row}" in ` +
419
- `section "${section.id}" rows (§4.6).`, { id: element.id, path: elementPath }));
420
- }
421
- const usesGrid = hasCol(element) || element.row !== undefined;
422
- const usesFreeform = hasAnyCoordinate(element);
423
- if (!usesGrid && !usesFreeform) {
424
- violations.push(warning('element-position', `Element "${element.id}" has neither "row"/"col" nor "x"/"y"; it cannot be ` +
425
- 'placed within the seating area (§4.4).', { id: element.id, path: elementPath }));
426
- return;
427
- }
428
- // §4.4.1 binds an element to its section's positioning mode exactly as §4.5
429
- // binds its seats: a section whose elements are addressed differently from
430
- // its seats cannot be laid out deterministically by two renderers. `mixed`
431
- // permits either, and a freeform element may still carry `row` as a label.
432
- if (mode === 'grid' && usesFreeform) {
433
- violations.push(error('element-layout-mismatch', `Element "${element.id}" carries "x"/"y" in grid section "${section.id}"; ` +
434
- 'a grid element is positioned by "row" and/or "col" (§4.4.1).', { id: element.id, path: elementPath }));
435
- }
436
- else if (mode === 'freeform' && hasCol(element)) {
437
- violations.push(error('element-layout-mismatch', `Element "${element.id}" carries "col" in freeform section ` +
438
- `"${section.id}", which has no column grid to place it in (§4.4.1).`, { id: element.id, path: elementPath }));
543
+ violations.push(...collectPositionViolations(element, 'element', ctx, elementPath));
544
+ for (const axis of ['width', 'height']) {
545
+ const size = element[axis];
546
+ if (size !== undefined && !isPositive(size)) {
547
+ violations.push(error('element-size-invalid', `Element "${element.id}" has "${axis}": ${size}; an element's size is a ` +
548
+ 'positive number of canvas units (§4.4.1).', { id: element.id, path: `${elementPath}.${axis}` }));
549
+ }
439
550
  }
440
- violations.push(...collectElementSpanViolations(element, section, mode, rowIndexes, elementPath));
441
551
  });
442
552
  return violations;
443
553
  }
444
554
  /**
445
- * §4.4.1 spans. For a grid-addressed element `width` and `height` are
446
- * dimensionless cell counts — positive integers, defaulting to 1 — so a
447
- * fractional or zero span leaves two renderers to disagree about how much space
448
- * it occupies. They are percentages once `x`/`y` governs placement, where any
449
- * positive number is meaningful, so nothing is checked there. A mixed section
450
- * can hold both kinds, one element at a time.
451
- *
452
- * A row span is also bounded, and only in a grid section as §4.6 scopes it: an
453
- * element reaching past the last row in the §4.2.1 order occupies rows that do
454
- * not exist.
555
+ * Every repeat of an id among `items`, reported at the repeat in the order the
556
+ * items are given. Items without a usable id are skipped; a missing id is its
557
+ * own rule.
455
558
  */
456
- function collectElementSpanViolations(element, section, mode, rowIndexes, elementPath) {
457
- const gridAddressed = mode === 'grid' || (mode === 'mixed' && !hasAnyCoordinate(element));
458
- if (!gridAddressed) {
459
- return [];
460
- }
559
+ function duplicateIds(items, rule, message) {
560
+ const seen = new Set();
461
561
  const violations = [];
462
- for (const [axis, span] of [
463
- ['width', element.width],
464
- ['height', element.height],
465
- ]) {
466
- if (span !== undefined && (!Number.isInteger(span) || span < 1)) {
467
- violations.push(error('element-span-invalid', `Element "${element.id}" has "${axis}": ${span} in grid section ` +
468
- `"${section.id}"; a grid span is a positive integer count of cells (§4.4.1).`, { id: element.id, path: elementPath }));
562
+ for (const item of items) {
563
+ const id = item?.id;
564
+ if (id === undefined || id === null) {
565
+ continue;
566
+ }
567
+ if (seen.has(id)) {
568
+ violations.push(error(rule, message(id), { id }));
469
569
  }
570
+ seen.add(id);
470
571
  }
471
- const start = mode === 'grid' && element.row !== undefined ? rowIndexes.get(element.row) : undefined;
472
- const span = element.height ?? 1;
473
- if (start !== undefined && Number.isInteger(span) && span >= 1) {
474
- const last = start + span - 1;
475
- if (last > rowIndexes.size - 1) {
476
- violations.push(error('element-row-span-overrun', `Element "${element.id}" spans ${span} rows from "${element.row}", ` +
477
- `reaching past the last row of section "${section.id}" (§4.2.1, §4.6).`, { id: element.id, path: elementPath }));
572
+ return violations;
573
+ }
574
+ // --- Warnings ---------------------------------------------------------------
575
+ /**
576
+ * §4.3.1: a validator SHOULD report seats whose footprints overlap. Every
577
+ * section shares the one canvas, so seats in different sections can overlap
578
+ * too. Each overlapping pair is reported once, at whichever seat comes later
579
+ * in the document.
580
+ */
581
+ function collectSeatOverlapWarnings(map) {
582
+ const placed = [];
583
+ map.sections.forEach((section, s) => {
584
+ const size = seatSizeOf(map, section);
585
+ if (!isObject(section) || !Array.isArray(section.seats) || !isPositive(size)) {
586
+ return;
587
+ }
588
+ section.seats.forEach((seat, i) => {
589
+ if (isNum(seat?.x) && isNum(seat?.y)) {
590
+ placed.push({
591
+ seat,
592
+ path: `sections[${s}].seats[${i}]`,
593
+ f: footprintOf(seat, size),
594
+ order: placed.length,
595
+ });
596
+ }
597
+ });
598
+ });
599
+ // Sweep along x: two footprints can only overlap when their centres are
600
+ // closer than the sum of their circumradii.
601
+ const byX = [...placed].sort((a, b) => a.f.x - b.f.x);
602
+ const reported = [];
603
+ for (let j = 0; j < byX.length; j++) {
604
+ const q = byX[j];
605
+ for (let k = j - 1; k >= 0; k--) {
606
+ const p = byX[k];
607
+ const reach = (p.f.size + q.f.size) * Math.SQRT1_2;
608
+ if (q.f.x - p.f.x > reach) {
609
+ break;
610
+ }
611
+ if (Math.abs(q.f.y - p.f.y) > reach || !footprintsOverlap(p.f, q.f)) {
612
+ continue;
613
+ }
614
+ const [first, second] = p.order < q.order ? [p, q] : [q, p];
615
+ reported.push({
616
+ order: second.order,
617
+ violation: warning('seat-overlap', `Seat "${second.seat.id}" overlaps seat "${first.seat.id}" (${first.path}) (§4.3.1).`, { id: second.seat.id, path: second.path }),
618
+ });
478
619
  }
479
620
  }
621
+ return reported.sort((a, b) => a.order - b.order).map((r) => r.violation);
622
+ }
623
+ /**
624
+ * §4.1: a section's seats SHOULD lie inside its `bounds`. Only the seat's
625
+ * centre is checked, as it is against the canvas.
626
+ */
627
+ function collectSectionBoundsWarnings(map) {
628
+ const violations = [];
629
+ map.sections.forEach((section, s) => {
630
+ const b = section?.bounds;
631
+ if (!isObject(b) ||
632
+ ![b.x, b.y, b.width, b.height].every(isNum) ||
633
+ !Array.isArray(section.seats)) {
634
+ return;
635
+ }
636
+ section.seats.forEach((seat, i) => {
637
+ if (!isNum(seat?.x) || !isNum(seat?.y)) {
638
+ return;
639
+ }
640
+ if (seat.x >= b.x && seat.x <= b.x + b.width && seat.y >= b.y && seat.y <= b.y + b.height) {
641
+ return;
642
+ }
643
+ violations.push(warning('seat-outside-section-bounds', `Seat "${seat.id}" at (${seat.x}, ${seat.y}) lies outside the bounds of section ` +
644
+ `"${section.id}" (${b.x}, ${b.y}, ${b.width} × ${b.height}) (§4.1).`, { id: seat.id, path: `sections[${s}].seats[${i}]` }));
645
+ });
646
+ });
480
647
  return violations;
481
648
  }
649
+ function isObject(value) {
650
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
651
+ }
652
+ function isNum(value) {
653
+ return typeof value === 'number' && Number.isFinite(value);
654
+ }
655
+ function isPositive(value) {
656
+ return isNum(value) && value > 0;
657
+ }
658
+ function isPoint(value) {
659
+ return Array.isArray(value) && value.length === 2 && value.every(isNum);
660
+ }
482
661
  function collectStateEnvelope(doc, typeName) {
483
662
  if (!doc || typeof doc !== 'object') {
484
663
  return [error('state-shape', `${typeName} is missing or not an object.`)];