@kerusiweb/core 2.3.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.
- package/LICENSE +22 -22
- package/README.md +70 -68
- package/dist/kerusi/index.d.ts +4 -3
- package/dist/kerusi/index.d.ts.map +1 -1
- package/dist/kerusi/index.js +4 -3
- package/dist/kerusi/index.js.map +1 -1
- package/dist/kerusi/kerusi-geometry.d.ts +47 -0
- package/dist/kerusi/kerusi-geometry.d.ts.map +1 -0
- package/dist/kerusi/kerusi-geometry.js +111 -0
- package/dist/kerusi/kerusi-geometry.js.map +1 -0
- package/dist/kerusi/kerusi-map.model.d.ts +121 -68
- package/dist/kerusi/kerusi-map.model.d.ts.map +1 -1
- package/dist/kerusi/kerusi-map.model.js +6 -2
- package/dist/kerusi/kerusi-map.model.js.map +1 -1
- package/dist/kerusi/kerusi-rows.d.ts +2 -2
- package/dist/kerusi/kerusi-rows.js +2 -2
- package/dist/kerusi/kerusi-validator.d.ts +18 -9
- package/dist/kerusi/kerusi-validator.d.ts.map +1 -1
- package/dist/kerusi/kerusi-validator.js +301 -122
- package/dist/kerusi/kerusi-validator.js.map +1 -1
- package/dist/render/build-render-model.d.ts +8 -4
- package/dist/render/build-render-model.d.ts.map +1 -1
- package/dist/render/build-render-model.js +147 -45
- package/dist/render/build-render-model.js.map +1 -1
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/map-layout.d.ts +92 -0
- package/dist/render/map-layout.d.ts.map +1 -0
- package/dist/render/map-layout.js +93 -0
- package/dist/render/map-layout.js.map +1 -0
- package/dist/render/navigation-order.d.ts +20 -10
- package/dist/render/navigation-order.d.ts.map +1 -1
- package/dist/render/navigation-order.js +72 -57
- package/dist/render/navigation-order.js.map +1 -1
- package/dist/render/render-model.d.ts +48 -26
- package/dist/render/render-model.d.ts.map +1 -1
- package/dist/render/seat-shapes.d.ts +3 -3
- package/dist/render/seat-shapes.js +3 -3
- package/dist/view/rtl.d.ts +1 -1
- package/dist/view/rtl.js +1 -1
- package/dist/view/seat-aria.d.ts +6 -0
- package/dist/view/seat-aria.d.ts.map +1 -1
- package/dist/view/seat-aria.js +8 -0
- package/dist/view/seat-aria.js.map +1 -1
- package/dist/view/seat-glyph.d.ts +1 -1
- package/dist/view/seat-glyph.d.ts.map +1 -1
- package/dist/view/viewport.d.ts +35 -7
- package/dist/view/viewport.d.ts.map +1 -1
- package/dist/view/viewport.js +56 -7
- package/dist/view/viewport.js.map +1 -1
- package/package.json +42 -42
- package/dist/kerusi/kerusi-layout-mode.d.ts +0 -58
- package/dist/kerusi/kerusi-layout-mode.d.ts.map +0 -1
- package/dist/kerusi/kerusi-layout-mode.js +0 -117
- package/dist/kerusi/kerusi-layout-mode.js.map +0 -1
- package/dist/render/section-layout.d.ts +0 -66
- package/dist/render/section-layout.d.ts.map +0 -1
- package/dist/render/section-layout.js +0 -186
- package/dist/render/section-layout.js.map +0 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolveSeatPrice } from './kerusi-price.js';
|
|
2
|
-
import {
|
|
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`, `
|
|
188
|
-
* `kerusi` declares a supported major version.
|
|
189
|
-
* -
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
199
|
-
*
|
|
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
|
-
*
|
|
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
|
|
243
|
-
*
|
|
244
|
-
* the
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
violations.push(error('
|
|
306
|
-
'
|
|
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 (
|
|
309
|
-
violations.push(error('
|
|
310
|
-
'
|
|
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,
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
`
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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
|
|
457
|
-
const
|
|
458
|
-
if (!gridAddressed) {
|
|
459
|
-
return [];
|
|
460
|
-
}
|
|
559
|
+
function duplicateIds(items, rule, message) {
|
|
560
|
+
const seen = new Set();
|
|
461
561
|
const violations = [];
|
|
462
|
-
for (const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.`)];
|