Skip to content

Commit 9e07b80

Browse files
committed
decks xml into mockDB and new page using DeckRendering
1 parent dc829fb commit 9e07b80

5 files changed

Lines changed: 411 additions & 15 deletions

File tree

concept-sandbox/fed-search-mock.json

Lines changed: 45 additions & 12 deletions
Large diffs are not rendered by default.

concept-sandbox/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,16 @@ <h2>Navigation</h2>
361361
<span class="icon">&#128269;</span> Search
362362
</button>
363363
</li>
364+
<li>
365+
<button
366+
hx-get="partials/deck-render.html"
367+
hx-target="#main-content"
368+
hx-swap="innerHTML"
369+
onclick="selectNav(this)"
370+
>
371+
<span class="icon">&#9635;</span> Deck Render
372+
</button>
373+
</li>
364374
<li>
365375
<button
366376
hx-get="partials/layouts/index.html"
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
<style>
2+
.deck-grid {
3+
display: flex;
4+
gap: 0.5rem;
5+
height: calc(100vh - var(--header-h) - 1rem);
6+
}
7+
.deck-grid > .card {
8+
margin: 0;
9+
min-width: 0;
10+
overflow-y: auto;
11+
height: 100%;
12+
}
13+
.deck-grid > .deck-list {
14+
flex: 0 0 280px;
15+
}
16+
.deck-grid > .deck-stage {
17+
flex: 1 1 auto;
18+
display: flex;
19+
flex-direction: column;
20+
overflow: hidden;
21+
}
22+
.deck-render h2 {
23+
margin-bottom: 12px;
24+
font-size: 18px;
25+
}
26+
.deck-list ul {
27+
list-style: none;
28+
padding: 0;
29+
}
30+
.deck-list li button {
31+
width: 100%;
32+
text-align: left;
33+
background: none;
34+
border: none;
35+
padding: 8px 10px;
36+
border-radius: var(--radius);
37+
font-size: 14px;
38+
font-family: inherit;
39+
color: var(--clr-text);
40+
cursor: pointer;
41+
transition: background var(--transition);
42+
display: flex;
43+
align-items: baseline;
44+
gap: 8px;
45+
}
46+
.deck-list li button:hover:not(:disabled) {
47+
background: var(--clr-bg);
48+
}
49+
.deck-list li button.selected {
50+
background: rgba(78, 108, 242, 0.1);
51+
color: var(--clr-accent);
52+
font-weight: 600;
53+
}
54+
.deck-list li button:disabled {
55+
color: var(--clr-text-dim);
56+
cursor: not-allowed;
57+
}
58+
.deck-list .dp-id {
59+
margin-left: auto;
60+
font-size: 11px;
61+
font-family: 'SF Mono', 'Fira Code', monospace;
62+
color: var(--clr-text-dim);
63+
font-weight: 400;
64+
}
65+
.deck-list .dp-sep {
66+
margin: 10px 2px 6px;
67+
padding-top: 10px;
68+
border-top: 1px solid var(--clr-border);
69+
font-size: 11px;
70+
text-transform: uppercase;
71+
letter-spacing: 1.2px;
72+
color: var(--clr-text-dim);
73+
}
74+
.deck-stage .stage-host {
75+
flex: 1 1 auto;
76+
display: flex;
77+
align-items: center;
78+
justify-content: center;
79+
min-height: 0;
80+
overflow: auto;
81+
}
82+
.deck-stage .stage-meta {
83+
flex-shrink: 0;
84+
padding-top: 12px;
85+
border-top: 1px solid var(--clr-border);
86+
font-size: 12px;
87+
font-family: 'SF Mono', 'Fira Code', monospace;
88+
color: var(--clr-text-dim);
89+
display: flex;
90+
gap: 16px;
91+
flex-wrap: wrap;
92+
}
93+
.deck-stage .stage-note {
94+
color: var(--clr-text-dim);
95+
font-size: 14px;
96+
line-height: 1.6;
97+
}
98+
.deck-stage .stage-note.error {
99+
color: #b42318;
100+
}
101+
</style>
102+
103+
<div class="deck-render deck-grid">
104+
<div class="card deck-list">
105+
<h2>Deck-Plans</h2>
106+
<ul id="deck-plan-list">
107+
<li><span class="stage-note">Loading…</span></li>
108+
</ul>
109+
</div>
110+
<div class="card deck-stage">
111+
<h2 id="deck-title">Deck rendering</h2>
112+
<div class="stage-host" id="deck-host">
113+
<span class="stage-note" id="deck-note">Fetching the renderer…</span>
114+
</div>
115+
<div class="stage-meta" id="deck-meta"></div>
116+
</div>
117+
</div>
118+
119+
<script type="module">
120+
/**
121+
* Read-only deck rendering, backed by the deck-plan editor's `<deck-rendering>`
122+
* custom element and fed from the same mock DB the federated search queries.
123+
*
124+
* The `decks` field on a deckPlan is that plan's whole NeTEx document as a
125+
* string — the shape Sobek is expected to deliver. Plans without one are
126+
* listed but inert; that is the common case on real data today.
127+
*/
128+
129+
/**
130+
* Pinned build of the editor's web-component entry. Self-contained (Vue is
131+
* bundled in) and it registers `<deck-rendering>` as an import side effect,
132+
* guarded upstream by `customElements.get` — so a repeat htmx swap is free.
133+
*/
134+
const EDITOR_URL =
135+
'https://cdn.jsdelivr.net/npm/@opentrainticketing/netex-deckplan-editor@2.0.5/dist/netex-deckplan-editor.es.js';
136+
137+
/** Custom-element tag the bundle registers. */
138+
const TAG = 'deck-rendering';
139+
140+
/** Stage padding (px) held back when fitting the drawing to the pane. */
141+
const FIT_PAD = 48;
142+
143+
/** px-per-metre clamps: under MIN the seat labels collapse, over MAX the deck overflows. */
144+
const MIN_SCALE = 10,
145+
MAX_SCALE = 140;
146+
147+
/** Debounce (ms) before a resize refits the scale. */
148+
const RESIZE_MS = 150;
149+
150+
const grid = document.querySelector('.deck-render');
151+
const listEl = document.getElementById('deck-plan-list');
152+
const hostEl = document.getElementById('deck-host');
153+
const titleEl = document.getElementById('deck-title');
154+
const metaEl = document.getElementById('deck-meta');
155+
156+
/**
157+
* Stylesheet for the element's shadow root.
158+
*
159+
* The package ships its renderer styles to a `dist/*.css` its `exports` map
160+
* does not expose, so nothing reaches the shadow root and seats would render
161+
* solid black on black. The sandbox therefore owns the appearance, painted
162+
* from its own `--clr-*` tokens. Selectors mirror what the renderer emits.
163+
*
164+
* @returns {CSSStyleSheet} A constructable sheet shared by every rendering.
165+
*/
166+
const mkSheet = () => {
167+
const v = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
168+
const sheet = new CSSStyleSheet();
169+
sheet.replaceSync(`
170+
.vehicle-frame { background-color: ${v('--clr-bg')}; border-radius: 4px; }
171+
.vehicle-deck { fill: ${v('--clr-surface')}; stroke: ${v('--clr-border')}; stroke-width: 2px; rx: 5px; }
172+
.seat .seat__base { fill: rgba(78, 108, 242, 0.12); stroke: ${v('--clr-accent')}; stroke-width: 1px; rx: 5px; }
173+
.seat .seat__backrest { fill: ${v('--clr-accent')}; }
174+
.seat__text { fill: ${v('--clr-text')}; stroke: none; pointer-events: none; }
175+
.door { fill: ${v('--clr-text-dim')}; stroke: ${v('--clr-text-dim')}; stroke-width: 1px; }
176+
/* Read-only: the element still emits \`select\`, but nothing consumes it. */
177+
.seat, .door { cursor: default; }
178+
`);
179+
return sheet;
180+
};
181+
182+
const note = (msg, isError) =>
183+
(hostEl.innerHTML = `<span class="stage-note${isError ? ' error' : ''}">${msg}</span>`);
184+
185+
/** Flatten a deck's spaces into the counts worth showing under the drawing. */
186+
const statsOf = deck => {
187+
const spaces = deck.deckspaces ?? [];
188+
const count = key => spaces.reduce((n, s) => n + (s[key]?.length ?? 0), 0);
189+
return {
190+
spaces: spaces.length,
191+
spots: count('passengerSpots'),
192+
entrances: count('deckEntrances'),
193+
};
194+
};
195+
196+
/**
197+
* Scale and orientation that draw `deck` largest inside the stage.
198+
*
199+
* A deck is a long strip — 13 × 2 m here — so laying it across a portrait
200+
* stage wastes most of the box and shrinks the seat labels past reading.
201+
* Both orientations are measured and the roomier one wins; on this pane that
202+
* is `vertical`, which lands near the 36 px/m hathor's own sidebar uses.
203+
*
204+
* @param {object} deck Deck from the renderer bundle's parser.
205+
* @returns {{vertical: boolean, scale: number}} Props for the element.
206+
*/
207+
const fit = deck => {
208+
const { width, height } = deck.getBoundingBox();
209+
const availW = hostEl.clientWidth - FIT_PAD,
210+
availH = hostEl.clientHeight - FIT_PAD;
211+
if (!width || !height || availW <= 0 || availH <= 0)
212+
return { vertical: false, scale: MIN_SCALE };
213+
214+
const contain = (w, h) => Math.min(availW / w, availH / h);
215+
const horiz = contain(width, height),
216+
vert = contain(height, width);
217+
const vertical = vert > horiz;
218+
return {
219+
vertical,
220+
scale: Math.max(MIN_SCALE, Math.min(MAX_SCALE, vertical ? vert : horiz)),
221+
};
222+
};
223+
224+
/**
225+
* Vue's dev/prod branches survive the library build as 213 bare
226+
* `process.env.NODE_ENV` reads — bundler consumers substitute them, a page
227+
* loading the raw ESM must not leave them undefined or the import throws
228+
* `process is not defined` before the element is ever registered.
229+
*/
230+
globalThis.process ??= { env: { NODE_ENV: 'production' } };
231+
232+
const [mod, db] = await Promise.all([
233+
import(EDITOR_URL)
234+
.then(m => customElements.whenDefined(TAG).then(() => m.default))
235+
.catch(() => null),
236+
fetch('fed-search-mock.json')
237+
.then(r => r.json())
238+
.catch(() => null),
239+
]);
240+
241+
if (!mod || !db) {
242+
listEl.innerHTML = '';
243+
note('Could not load the renderer bundle or the mock database.', true);
244+
} else {
245+
const sheet = mkSheet();
246+
const plans = db.deckPlans ?? [];
247+
const drawable = plans.filter(p => p.decks);
248+
let current = null;
249+
250+
/**
251+
* Draw one plan's first deck.
252+
*
253+
* The element is built detached and populated before insertion: Vue renders
254+
* in `connectedCallback` and dereferences `deck.getBoundingBox()` on that
255+
* first pass, so an element inserted before `deck` is assigned throws.
256+
*
257+
* The deck must come from *this* bundle's `parseNeTEx` — the renderer
258+
* matches deck spaces with `instanceof`, so a deck parsed by any other copy
259+
* of the models draws an empty outline.
260+
*
261+
* @param {object} plan Mock-DB deckPlan carrying a `decks` NeTEx string.
262+
*/
263+
const draw = plan => {
264+
current = plan;
265+
titleEl.textContent = plan.name;
266+
let deck;
267+
try {
268+
deck = mod.parseNeTEx(plan.decks)[0]?.decks[0];
269+
} catch (e) {
270+
metaEl.textContent = '';
271+
return note(`Could not parse this plan: ${e.message}`, true);
272+
}
273+
if (!deck) {
274+
metaEl.textContent = '';
275+
return note('This document carries no decks.', true);
276+
}
277+
278+
const el = document.createElement(TAG);
279+
Object.assign(el, { deck, ...fit(deck) });
280+
// Adopted before insertion so the drawing never flashes unstyled.
281+
if (el.shadowRoot?.adoptedStyleSheets) el.shadowRoot.adoptedStyleSheets = [sheet];
282+
hostEl.replaceChildren(el);
283+
284+
const s = statsOf(deck);
285+
metaEl.innerHTML = [
286+
plan.id,
287+
`${s.spots} spots`,
288+
`${s.spaces} space${s.spaces === 1 ? '' : 's'}`,
289+
`${s.entrances} entrances`,
290+
`${deck.Length} × ${deck.Width} m`,
291+
]
292+
.map(t => `<span>${t}</span>`)
293+
.join('');
294+
};
295+
296+
const row = (plan, enabled) => {
297+
const li = document.createElement('li');
298+
const btn = document.createElement('button');
299+
btn.disabled = !enabled;
300+
btn.innerHTML = `<span>${enabled ? '' : '⊘ '}${plan.name}</span><span class="dp-id">${plan.id}</span>`;
301+
btn.title = enabled ? plan.description : `${plan.description} — no decks in this plan`;
302+
if (enabled)
303+
btn.onclick = () => {
304+
listEl.querySelectorAll('button').forEach(b => b.classList.remove('selected'));
305+
btn.classList.add('selected');
306+
draw(plan);
307+
};
308+
li.append(btn);
309+
return li;
310+
};
311+
312+
listEl.innerHTML = '';
313+
drawable.forEach(p => listEl.append(row(p, true)));
314+
if (drawable.length < plans.length) {
315+
const sep = document.createElement('li');
316+
sep.className = 'dp-sep';
317+
sep.textContent = 'No decks stored';
318+
listEl.append(sep);
319+
plans.filter(p => !p.decks).forEach(p => listEl.append(row(p, false)));
320+
}
321+
322+
if (drawable.length) listEl.querySelector('button').click();
323+
else note('No deck plan in the database carries a decks document.', true);
324+
325+
// Refit on resize. Self-removing: the partial is swapped out by htmx, which
326+
// gives no unmount hook, so the handler drops itself once its DOM is gone.
327+
let timer;
328+
const onResize = () => {
329+
if (!document.body.contains(grid)) return window.removeEventListener('resize', onResize);
330+
clearTimeout(timer);
331+
timer = setTimeout(() => current && draw(current), RESIZE_MS);
332+
};
333+
window.addEventListener('resize', onResize);
334+
}
335+
</script>

concept-sandbox/partials/fed-db.html

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,29 @@ <h2>Database:</h2>
9494
{ key: 'deckPlans', label: 'Deck-Plans', title: v => v.name },
9595
];
9696

97+
/**
98+
* Head kept of an over-long string field. The `decks` NeTEx documents run to
99+
* tens of thousands of characters, which would bury every other field in the
100+
* tree; the length note keeps the payload visible without printing it. Sits
101+
* clear of the longest prose field (a 64-char description) so only those
102+
* documents collapse.
103+
*/
104+
const VAL_CAP = 96;
105+
106+
const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
107+
108+
const fmtStr = v =>
109+
v.length > VAL_CAP
110+
? `<span class="tf-str">"${esc(v.slice(0, VAL_CAP))}\u2026"</span>` +
111+
` <span class="tf-key">(${v.length.toLocaleString()} chars)</span>`
112+
: `<span class="tf-str">"${esc(v)}"</span>`;
113+
97114
const fmtVal = v =>
98115
typeof v === 'string'
99-
? `<span class="tf-str">"${v}"</span>`
116+
? fmtStr(v)
100117
: typeof v === 'number'
101118
? `<span class="tf-num">${v}</span>`
102-
: String(v);
119+
: `<span class="tf-key">${v}</span>`;
103120

104121
const accordion = document.getElementById('db-accordion');
105122

concept-sandbox/partials/fed-search-query.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ <h1 style="margin-bottom: 8px">Federated Search</h1>
240240
}
241241
type VehicleType { id: ID!, name: String!, shortName: String, transportMode: String, length: Float, width: Float, height: Float, vehicleCount: Int }
242242
type Vehicle { id: ID!, registrationNumber: String!, version: Int, operationalNumber: String, vehicleTypeName: String }
243-
type DeckPlan { id: ID!, name: String!, description: String, deckCount: Int }
243+
"decks is the plan's whole NeTEx document — tens of KB, so no list view selects it."
244+
type DeckPlan { id: ID!, name: String!, description: String, deckCount: Int, decks: String }
244245
`);
245246

246247
const vtByName = Object.fromEntries(data.vehicleTypes.map(v => [v.name, v]));

0 commit comments

Comments
 (0)