Enhance UI components for map editing and school creation. Update styles for improved layout and responsiveness, including adjustments to dialog positioning and flex properties. Introduce new localization strings for better user guidance in both English and Russian. Refactor code structure for clarity and maintainability, ensuring a more intuitive user experience.
ci / server (push) Failing after 3m32s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 16:34:35 +03:00
parent 3068d10409
commit 7658443cd5
9 changed files with 378 additions and 119 deletions
+3
View File
@@ -130,6 +130,9 @@ say so explicitly in the change description.
- **`<dialog>`'s `close` event is not delivered by every engine** (Chromium 148 fires only - **`<dialog>`'s `close` event is not delivered by every engine** (Chromium 148 fires only
`toggle`). `ui/modal.ts` resolves its promise explicitly on every exit for that reason — never go `toggle`). `ui/modal.ts` resolves its promise explicitly on every exit for that reason — never go
back to awaiting the event, or a dialog will silently freeze the screen that awaited it. back to awaiting the event, or a dialog will silently freeze the screen that awaited it.
- **A modal `<dialog>` is absolutely positioned with `inset: 0`,** so `height: auto` stretches it
to its `max-height` instead of shrink-wrapping. `.dialog--screen` uses `height: fit-content` for
that reason — the map editor sized itself to 800px around a two-room school before it did.
- **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration - **Game dates are UTC on the wire and UTC when formatted.** A `DateTime` bound from configuration
arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own arrives as `Kind=Unspecified`, serializes without a `Z`, and the browser then reads it in its own
time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason. time zone — `SimulationOptions.DefaultStartDate` forces the kind for exactly that reason.
+10 -2
View File
@@ -51,7 +51,11 @@ const ru = {
mapEditedHint: 'Карта изменена.', mapEditedHint: 'Карта изменена.',
resetMap: 'Сбросить к умолчанию', resetMap: 'Сбросить к умолчанию',
done: 'Готово', done: 'Готово',
editorSlots: 'Слоты', editorStructure: 'Структура',
editorSlots: 'Обстановка',
editorPickRoom: 'Выберите комнату в дереве слева.',
editorRooms: 'Комнаты',
editorNoLinks: 'Проходов нет.',
editorLinks: 'Проходы', editorLinks: 'Проходы',
addLink: 'Связать', addLink: 'Связать',
removeLink: 'Убрать', removeLink: 'Убрать',
@@ -130,7 +134,11 @@ const en: Messages = {
mapEditedHint: 'The map has been edited.', mapEditedHint: 'The map has been edited.',
resetMap: 'Reset to default', resetMap: 'Reset to default',
done: 'Done', done: 'Done',
editorSlots: 'Slots', editorStructure: 'Structure',
editorSlots: 'Furnishing',
editorPickRoom: 'Pick a room in the tree on the left.',
editorRooms: 'Rooms',
editorNoLinks: 'No passages yet.',
editorLinks: 'Passages', editorLinks: 'Passages',
addLink: 'Link', addLink: 'Link',
removeLink: 'Remove', removeLink: 'Remove',
+181 -55
View File
@@ -3,6 +3,7 @@
--surface: #10141c; --surface: #10141c;
--surface-raised: #171d28; --surface-raised: #171d28;
--surface-sunken: #0c1017;
--border: #2a3242; --border: #2a3242;
--text: #d7e0ef; --text: #d7e0ef;
--text-muted: #8b98ad; --text-muted: #8b98ad;
@@ -19,14 +20,39 @@
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100dvh;
background: var(--surface); background: var(--surface);
} }
/*
* The shell fills the window. A management screen that stops halfway down a very wide page reads
* as a half-loaded document rather than a game, so every screen is a full-height flex column and
* decides its own comfortable width.
*/
#app { #app {
max-width: 1080px; display: flex;
min-height: 100dvh;
padding: 24px 24px 52px;
}
.screen {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 32px 20px 72px; }
/* A short list of saves sits in the middle of the window instead of clinging to the top edge. */
.screen.menu {
max-width: 900px;
justify-content: center;
}
.screen.game {
max-width: 1500px;
} }
#status { #status {
@@ -73,7 +99,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 16px;
} }
.screen__title { .screen__title {
@@ -100,15 +126,16 @@ body {
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
align-content: start;
gap: 16px; gap: 16px;
} }
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
padding: 16px; padding: 18px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
background: var(--surface-raised); background: var(--surface-raised);
@@ -151,75 +178,109 @@ body {
} }
.card__actions { .card__actions {
margin-top: 8px; display: flex;
justify-content: flex-end;
margin-top: 10px;
} }
/* Clock */ /* Clock */
.clock { /*
padding: 28px; * Inside a school the calendar is a toolbar, not a hero banner: the panels below are the screen,
* and a 56px centred clock pushed them down for no information gained.
*/
.clockbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px 18px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 14px;
background: var(--surface-raised); background: var(--surface-raised);
text-align: center; }
.clockbar__now {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
} }
.clock__time { .clock__time {
margin: 0; margin: 0;
font-size: 56px; font-size: 34px;
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: 0.02em; letter-spacing: 0.02em;
line-height: 1.1;
}
.clockbar__labels {
display: flex;
flex-direction: column;
min-width: 0;
} }
.clock__date { .clock__date {
margin: 4px 0 0; margin: 0;
font-size: 18px; font-size: 15px;
} }
.clock__weekday { .clock__weekday {
margin: 2px 0 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
font-size: 13px;
text-transform: capitalize; text-transform: capitalize;
} }
.clock__controls { .clock__controls {
display: flex; display: flex;
justify-content: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 20px; margin-left: auto;
}
.screen.game .clock {
padding: 20px;
}
.screen.game .clock__time {
font-size: 40px;
} }
/* Manager */ /* Manager */
.manager { .manager {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr) minmax(240px, 1.35fr); grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr);
gap: 16px; gap: 16px;
margin-top: 20px; flex: 1;
min-height: 0;
margin-top: 16px;
} }
.panel { .panel {
display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
padding: 16px; min-height: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
background: var(--surface-raised); background: var(--surface-raised);
} }
.panel__title { .panel__title {
margin: 0 0 12px; flex: 0 0 auto;
font-size: 15px; margin: 0;
padding: 11px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* The body scrolls, the title stays: a long tree must not push the panel out of the window. */
.panel__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 12px 16px;
} }
.panel__name { .panel__name {
@@ -229,7 +290,11 @@ body {
} }
.panel__section { .panel__section {
margin-top: 12px; margin-top: 14px;
}
.panel__section:first-child {
margin-top: 0;
} }
.panel__section-title { .panel__section-title {
@@ -286,9 +351,20 @@ body {
color: var(--accent); color: var(--accent);
} }
@media (max-width: 800px) { /* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) {
#app {
min-height: 0;
}
.manager { .manager {
grid-template-columns: 1fr; display: flex;
flex-direction: column;
flex: 0 0 auto;
}
.panel__body {
overflow: visible;
} }
} }
@@ -302,6 +378,7 @@ body {
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 14px; font-size: 14px;
white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: border-color 120ms ease, background 120ms ease; transition: border-color 120ms ease, background 120ms ease;
} }
@@ -342,11 +419,11 @@ body {
} }
.input { .input {
flex: 1; min-width: 0;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface-sunken);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 14px; font-size: 14px;
@@ -361,7 +438,7 @@ body {
.dialog { .dialog {
min-width: 340px; min-width: 340px;
max-width: 440px; max-width: 460px;
padding: 24px; padding: 24px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
@@ -369,13 +446,20 @@ body {
color: var(--text); color: var(--text);
} }
/*
* The editor grows with its map instead of always claiming 800px: a two-room school framed by a
* half-empty window was the loudest thing on screen.
*/
.dialog--screen[open] { .dialog--screen[open] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: unset; min-width: unset;
max-width: none; max-width: none;
width: min(1080px, calc(100vw - 24px)); width: min(1080px, calc(100vw - 32px));
height: min(800px, calc(100vh - 24px)); /* A modal dialog is absolutely positioned with `inset: 0`, so `height: auto` stretches it to the
max instead of shrink-wrapping. `fit-content` is what actually lets it size to its map. */
height: fit-content;
max-height: calc(100dvh - 48px);
padding: 16px 20px 20px; padding: 16px 20px 20px;
} }
@@ -389,21 +473,12 @@ body {
} }
.dialog--screen .map-editor { .dialog--screen .map-editor {
flex: 1; flex: 1 1 auto;
min-height: 0; min-height: min(400px, 55dvh);
grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.3fr); grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.3fr);
gap: 16px; gap: 16px;
} }
.dialog--screen .map-editor__pane,
.dialog--screen .map-editor__details {
min-height: 0;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
}
.dialog--screen .dialog__actions { .dialog--screen .dialog__actions {
flex: 0 0 auto; flex: 0 0 auto;
margin-top: 12px; margin-top: 12px;
@@ -453,6 +528,20 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
/*
* Without this the rows fell back to inline-block flow, `.input { flex: 1 }` never applied and
* every field in the create form ended up a different width.
*/
.field__row {
display: flex;
align-items: center;
gap: 8px;
}
.field__row > .input {
flex: 1;
}
.field .hint { .field .hint {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
@@ -460,8 +549,8 @@ body {
.mod-list { .mod-list {
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 6px; gap: 8px 16px;
} }
.mod-list__item { .mod-list__item {
@@ -471,16 +560,53 @@ body {
font-size: 14px; font-size: 14px;
} }
/* Map editor */
.map-editor { .map-editor {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); grid-template-columns: minmax(200px, 0.9fr) minmax(280px, 1.3fr);
gap: 12px; gap: 12px;
} }
.map-editor__pane,
.map-editor__details { .map-editor__details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; min-width: 0;
min-height: 0;
gap: 12px;
overflow: auto;
}
.editor-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-sunken);
}
.editor-section--grow {
flex: 1;
min-height: 0;
overflow: auto;
}
.editor-section__title {
margin: 0;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.editor-section__hint {
margin: 0;
color: var(--text-muted);
font-size: 13px;
} }
.map-editor__links { .map-editor__links {
@@ -41,7 +41,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const mapLabel = el('span', { class: 'field__label' }); const mapLabel = el('span', { class: 'field__label' });
const editMapButton = el('button', { class: 'button', type: 'button' }); const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' }); const mapHint = el('p', { class: 'hint' });
const mapField = el('div', { class: 'field' }, mapLabel, editMapButton, mapHint); const mapField = el(
'div',
{ class: 'field' },
mapLabel,
el('div', { class: 'field__row' }, editMapButton, mapHint),
);
const nameInput = el('input', { class: 'input', type: 'text' }); const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40; nameInput.maxLength = 40;
@@ -65,12 +70,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const nameLabel = el('span', { class: 'field__label' }); const nameLabel = el('span', { class: 'field__label' });
const startLabel = el('span', { class: 'field__label' }); const startLabel = el('span', { class: 'field__label' });
// Name first: it is what the player came to type. Mods and the map are setup they usually leave
// alone, so they sit below rather than in front of the field the dialog focuses.
const form = el( const form = el(
'form', 'form',
{ class: 'form' }, { class: 'form' },
modsField,
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)), el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)), el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
mapField, mapField,
error, error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton), el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
+28 -11
View File
@@ -69,26 +69,43 @@ export class GameScreen {
el('header', { class: 'screen__header' }, this.backButton, this.schoolName), el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el( el(
'div', 'div',
{ class: 'clock' }, { class: 'clockbar' },
this.time, el(
this.date, 'div',
this.weekday, { class: 'clockbar__now' },
this.time,
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
),
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons), el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
), ),
el( el(
'div', 'div',
{ class: 'manager' }, { class: 'manager' },
el('section', { class: 'panel' }, this.mapTitle, this.tree), el(
el('section', { class: 'panel' }, this.eventsTitle, this.eventsEmpty), 'section',
{ class: 'panel' },
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
),
el( el(
'section', 'section',
{ class: 'panel' }, { class: 'panel' },
this.locationTitle, this.locationTitle,
this.locationName, el(
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList), 'div',
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), { class: 'panel__body' },
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), this.locationName,
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
),
), ),
), ),
); );
+91 -45
View File
@@ -23,11 +23,16 @@ export function mapEditor(options: MapEditorOptions): {
let selectedId: string | null = map.territory.id; let selectedId: string | null = map.territory.id;
const tree = el('ul', { class: 'tree map-editor__tree' }); const tree = el('ul', { class: 'tree map-editor__tree' });
const treeTitle = el('h3', { class: 'editor-section__title' });
const details = el('div', { class: 'map-editor__details' }); const details = el('div', { class: 'map-editor__details' });
const root = el( const root = el(
'div', 'div',
{ class: 'map-editor' }, { class: 'map-editor' },
el('div', { class: 'map-editor__pane' }, tree), el(
'div',
{ class: 'map-editor__pane' },
el('section', { class: 'editor-section editor-section--grow' }, treeTitle, tree),
),
details, details,
); );
@@ -37,10 +42,20 @@ export function mapEditor(options: MapEditorOptions): {
}; };
const render = (): void => { const render = (): void => {
treeTitle.textContent = t('editorStructure');
paintTree(); paintTree();
paintDetails(); paintDetails();
}; };
/** Every group of controls gets a frame and a heading; loose selects read as debug output. */
const section = (title: string, ...children: (Node | null)[]): HTMLElement =>
el(
'section',
{ class: 'editor-section' },
el('h3', { class: 'editor-section__title', text: title }),
...children,
);
const paintTree = (): void => { const paintTree = (): void => {
clear(tree); clear(tree);
appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0); appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0);
@@ -48,10 +63,7 @@ export function mapEditor(options: MapEditorOptions): {
for (const building of map.buildings) { for (const building of map.buildings) {
appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1); appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1);
for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) { for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) {
const floorName = floor.label !== undefined && floor.label.length > 0 appendNode(tree, floor.id, floorName(catalog, floor), 2);
? floor.label
: (defLabel(catalog.floors, floor.def) ?? floor.id);
appendNode(tree, floor.id, floorName, 2);
for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) { for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) {
appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3); appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3);
} }
@@ -77,42 +89,58 @@ export function mapEditor(options: MapEditorOptions): {
const paintDetails = (): void => { const paintDetails = (): void => {
clear(details); clear(details);
details.append(paintSelected(), paintLinks(), paintRooms());
};
/** Furnishing of the selected room, or the reason there is nothing to furnish. */
const paintSelected = (): HTMLElement => {
const room = map.rooms.find((candidate) => candidate.id === selectedId); const room = map.rooms.find((candidate) => candidate.id === selectedId);
const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def); const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def);
const title = `${t('editorSlots')}${labelOf(catalog, map, selectedId ?? map.territory.id)}`;
if (room !== undefined && roomDef !== undefined) { if (room === undefined || roomDef === undefined) {
details.append(el('p', { class: 'field__label', text: t('editorSlots') })); return section(title, el('p', { class: 'editor-section__hint', text: t('editorPickRoom') }));
for (const slot of roomDef.slots) { }
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key); const rows: HTMLElement[] = [];
select.value = fill?.thing ?? ''; for (const slot of roomDef.slots) {
select.addEventListener('change', () => { const select = el('select', { class: 'input' });
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key); const empty = el('option', { text: t('slotEmpty') });
if (select.value !== '') { empty.value = '';
slots.push({ key: slot.key, thing: select.value }); select.append(empty);
} for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
room.slots = slots; option.value = thing.defName;
emit(); select.append(option);
});
details.append(
el('label', { class: 'field' }, el('span', { class: 'field__label', text: slot.key }), select),
);
} }
const remove = el('button', { const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
class: 'button', select.value = fill?.thing ?? '';
select.addEventListener('change', () => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
slots.push({ key: slot.key, thing: select.value });
}
room.slots = slots;
emit();
});
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
),
);
}
return section(
title,
...rows,
el('div', { class: 'dialog__actions' }, el('button', {
class: 'button button--danger button--small',
type: 'button', type: 'button',
text: t('removeRoom'), text: t('removeRoom'),
onClick: () => { onClick: () => {
@@ -121,11 +149,11 @@ export function mapEditor(options: MapEditorOptions): {
selectedId = map.territory.id; selectedId = map.territory.id;
emit(); emit();
}, },
}); })),
details.append(remove); );
} };
details.append(el('p', { class: 'field__label', text: t('editorLinks') })); const paintLinks = (): HTMLElement => {
const linksList = el('ul', { class: 'map-editor__links' }); const linksList = el('ul', { class: 'map-editor__links' });
for (const [index, link] of map.links.entries()) { for (const [index, link] of map.links.entries()) {
const row = el('li', { class: 'map-editor__link' }); const row = el('li', { class: 'map-editor__link' });
@@ -144,12 +172,13 @@ export function mapEditor(options: MapEditorOptions): {
linksList.append(row); linksList.append(row);
} }
details.append(linksList);
const walkable = walkableIds(map); const walkable = walkableIds(map);
const from = selectOf(walkable, catalog, map); const from = selectOf(walkable, catalog, map);
const to = selectOf(walkable, catalog, map); const to = selectOf(walkable, catalog, map);
details.append(
return section(
t('editorLinks'),
map.links.length > 0 ? linksList : el('p', { class: 'editor-section__hint', text: t('editorNoLinks') }),
el( el(
'div', 'div',
{ class: 'field__row' }, { class: 'field__row' },
@@ -172,7 +201,9 @@ export function mapEditor(options: MapEditorOptions): {
}), }),
), ),
); );
};
const paintRooms = (): HTMLElement => {
const roomSelect = el('select', { class: 'input' }); const roomSelect = el('select', { class: 'input' });
for (const def of catalog.rooms) { for (const def of catalog.rooms) {
const option = el('option', { text: def.label }); const option = el('option', { text: def.label });
@@ -180,7 +211,8 @@ export function mapEditor(options: MapEditorOptions): {
roomSelect.append(option); roomSelect.append(option);
} }
details.append( return section(
t('editorRooms'),
el( el(
'div', 'div',
{ class: 'field__row' }, { class: 'field__row' },
@@ -277,6 +309,22 @@ function slug(defName: string): string {
.toLowerCase(); .toLowerCase();
} }
/**
* A floor's `label` is its designation, not a replacement name — the vanilla map says "1". Shown
* on its own it is a stray digit in the tree, so it follows the def label: "Этаж 1", "Floor 1".
* Mirrors `MapView.FloorName` on the server.
*/
function floorName(catalog: CatalogResponse, floor: MapLayout['floors'][number]): string {
const def = defLabel(catalog.floors, floor.def);
const label = floor.label ?? '';
if (def === undefined) {
return label.length > 0 ? label : floor.id;
}
return label.length > 0 ? `${def} ${label}` : def;
}
function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined { function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined {
return defs.find((def) => def.defName === defName)?.label; return defs.find((def) => def.defName === defName)?.label;
} }
@@ -293,9 +341,7 @@ function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string {
const floor = map.floors.find((node) => node.id === id); const floor = map.floors.find((node) => node.id === id);
if (floor !== undefined) { if (floor !== undefined) {
return floor.label !== undefined && floor.label.length > 0 return floorName(catalog, floor);
? floor.label
: (defLabel(catalog.floors, floor.def) ?? id);
} }
const room = map.rooms.find((node) => node.id === id); const room = map.rooms.find((node) => node.id === id);
+17 -3
View File
@@ -63,13 +63,11 @@ public static class MapView
foreach (var floor in map.Floors) foreach (var floor in map.Floors)
{ {
var defLabel = LabelOf(catalog, locale, DefKind.Floor, floor.Def);
var name = string.IsNullOrWhiteSpace(floor.Label) ? defLabel : floor.Label;
nodes.Add(Node( nodes.Add(Node(
MapNodeKind.Floor, MapNodeKind.Floor,
floor.Id, floor.Id,
floor.Building, floor.Building,
name, FloorName(catalog, locale, floor),
[], [],
PositionsOf(catalog, locale, DefKind.Floor, floor.Def))); PositionsOf(catalog, locale, DefKind.Floor, floor.Def)));
} }
@@ -111,6 +109,22 @@ public static class MapView
Positions = positions, Positions = positions,
}; };
/// <summary>
/// A floor's <see cref="FloorNode.Label"/> is its designation, not a replacement name — the
/// vanilla map says "1". On its own that is a stray digit in the client's tree, so it follows
/// the localized def label: "Этаж 1", "Floor 1". Mirrored by <c>floorName</c> in the editor.
/// </summary>
private static string FloorName(DefCatalog catalog, string locale, FloorNode floor)
{
var defLabel = LabelOf(catalog, locale, DefKind.Floor, floor.Def);
if (string.IsNullOrWhiteSpace(floor.Label))
{
return defLabel;
}
return defLabel == floor.Def ? floor.Label : $"{defLabel} {floor.Label}";
}
private static string LabelOf(DefCatalog catalog, string locale, DefKind kind, string defName) => private static string LabelOf(DefCatalog catalog, string locale, DefKind kind, string defName) =>
catalog.TryGet(kind, defName, out var def) ? catalog.Label(locale, def) : defName; catalog.TryGet(kind, defName, out var def) ? catalog.Label(locale, def) : defName;
@@ -9,7 +9,7 @@
"WalkSchool": "Обход школы", "WalkSchool": "Обход школы",
"SchoolYard": "Двор", "SchoolYard": "Двор",
"MainBuilding": "Главный корпус", "MainBuilding": "Главный корпус",
"StandardFloor": "Этажи", "StandardFloor": "Этаж",
"Corridor": "Коридор", "Corridor": "Коридор",
"PrincipalsOffice": "Кабинет директора", "PrincipalsOffice": "Кабинет директора",
} }
@@ -33,4 +33,42 @@ public class MapViewTests
Assert.Empty(corridor.Items); Assert.Empty(corridor.Items);
Assert.Empty(corridor.Positions); Assert.Empty(corridor.Positions);
} }
[Fact]
public void FloorLabel_FollowsTheDefLabelInsteadOfReplacingIt()
{
var catalog = new CatalogLoader().Load(
[CatalogLoader.CorePackId],
[
new ContentDocument(
CatalogLoader.CorePackId,
"defs/floors/standard.jsonc",
"""{ "defName": "StandardFloor" }"""),
new ContentDocument(
CatalogLoader.CorePackId,
"defs/territories/yard.jsonc",
"""{ "defName": "Yard" }"""),
new ContentDocument(
CatalogLoader.CorePackId,
"localizations/ru.jsonc",
"""{ "StandardFloor": "Этаж", "Yard": "Двор" }"""),
]);
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors =
[
new FloorNode { Id = "floor-1", Def = "StandardFloor", Building = "main", Label = "1" },
new FloorNode { Id = "floor-2", Def = "StandardFloor", Building = "main" },
],
};
var nodes = MapView.Build(catalog, map, "ru");
// A bare "1" in the tree is the label on its own; the def label carries the noun.
Assert.Equal("Этаж 1", nodes.Single(node => node.Id == "floor-1").Name);
Assert.Equal("Этаж", nodes.Single(node => node.Id == "floor-2").Name);
}
} }