Files
h-school/src/HSchool.Client/src/ui/mapEditor.ts
T

376 lines
11 KiB
TypeScript

import type { CatalogResponse, MapLayout, RoomInfo } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
interface MapEditorOptions {
catalog: CatalogResponse;
map: MapLayout;
onChange: (map: MapLayout) => void;
}
/**
* Create-only map editor: tree, slot fills, passage links, add/remove room, reset to the pack default.
* The server still validates the graph on POST.
*/
export function mapEditor(options: MapEditorOptions): {
readonly element: HTMLElement;
setCatalog: (catalog: CatalogResponse, map: MapLayout) => void;
getMap: () => MapLayout;
localize: () => void;
} {
let catalog = options.catalog;
let map = cloneMap(options.map);
let selectedId: string | null = map.territory.id;
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 root = el(
'div',
{ class: 'map-editor' },
el(
'div',
{ class: 'map-editor__pane' },
el('section', { class: 'editor-section editor-section--grow' }, treeTitle, tree),
),
details,
);
const emit = (): void => {
options.onChange(cloneMap(map));
render();
};
const render = (): void => {
treeTitle.textContent = t('editorStructure');
paintTree();
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 => {
clear(tree);
appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0);
for (const building of map.buildings) {
appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1);
for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) {
appendNode(tree, floor.id, floorName(catalog, floor), 2);
for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) {
appendNode(tree, room.id, roomName(catalog, room), 3);
}
}
}
};
const appendNode = (list: HTMLUListElement, id: string, name: string, depth: number): void => {
const item = el('li', { class: 'tree__node' });
const button = el('button', {
class: selectedId === id ? 'tree__button tree__button--active' : 'tree__button',
type: 'button',
text: name,
onClick: () => {
selectedId = id;
render();
},
});
button.style.paddingLeft = `${8 + depth * 14}px`;
item.append(button);
list.append(item);
};
const paintDetails = (): void => {
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 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) {
return section(title, el('p', { class: 'editor-section__hint', text: t('editorPickRoom') }));
}
const rows: HTMLElement[] = [];
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);
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',
text: t('removeRoom'),
onClick: () => {
map.rooms = map.rooms.filter((candidate) => candidate.id !== room.id);
map.links = map.links.filter((link) => link.a !== room.id && link.b !== room.id);
selectedId = map.territory.id;
emit();
},
})),
);
};
const paintLinks = (): HTMLElement => {
const linksList = el('ul', { class: 'map-editor__links' });
for (const [index, link] of map.links.entries()) {
const row = el('li', { class: 'map-editor__link' });
row.append(
el('span', { text: `${labelOf(catalog, map, link.a)}${labelOf(catalog, map, link.b)}` }),
el('button', {
class: 'button button--small',
type: 'button',
text: t('removeLink'),
onClick: () => {
map.links = map.links.filter((_, current) => current !== index);
emit();
},
}),
);
linksList.append(row);
}
const walkable = walkableIds(map);
const from = selectOf(walkable, catalog, map);
const to = selectOf(walkable, catalog, map);
return section(
t('editorLinks'),
map.links.length > 0 ? linksList : el('p', { class: 'editor-section__hint', text: t('editorNoLinks') }),
el(
'div',
{ class: 'field__row' },
from,
to,
el('button', {
class: 'button',
type: 'button',
text: t('addLink'),
onClick: () => {
const a = from.value;
const b = to.value;
if (a === b || hasLink(map, a, b)) {
return;
}
map.links.push({ a, b });
emit();
},
}),
),
);
};
const paintRooms = (): HTMLElement => {
const roomSelect = el('select', { class: 'input' });
for (const def of catalog.rooms) {
const option = el('option', { text: def.label });
option.value = def.defName;
roomSelect.append(option);
}
return section(
t('editorRooms'),
el(
'div',
{ class: 'field__row' },
roomSelect,
el('button', {
class: 'button',
type: 'button',
text: t('addRoom'),
disabled: catalog.rooms.length === 0 || map.buildings.length === 0 || map.floors.length === 0,
onClick: () => {
const def = catalog.rooms.find((candidate) => candidate.defName === roomSelect.value);
const building = map.buildings[0];
const floor = map.floors[0];
if (def === undefined || building === undefined || floor === undefined) {
return;
}
const id = nextId(slug(def.defName), usedIds(map));
map.rooms.push({
id,
def: def.defName,
building: building.id,
floor: floor.id,
slots: defaultSlots(def),
});
if (!hasLink(map, map.territory.id, id)) {
map.links.push({ a: map.territory.id, b: id });
}
selectedId = id;
emit();
},
}),
),
);
};
render();
return {
element: root,
setCatalog: (nextCatalog, nextMap) => {
catalog = nextCatalog;
map = cloneMap(nextMap);
selectedId = map.territory.id;
render();
},
getMap: () => cloneMap(map),
localize: () => render(),
};
}
function cloneMap(map: MapLayout): MapLayout {
return structuredClone(map);
}
function usedIds(map: MapLayout): Set<string> {
return new Set([
map.territory.id,
...map.buildings.map((node) => node.id),
...map.floors.map((node) => node.id),
...map.rooms.map((node) => node.id),
]);
}
function walkableIds(map: MapLayout): string[] {
return [map.territory.id, ...map.rooms.map((room) => room.id)];
}
function hasLink(map: MapLayout, a: string, b: string): boolean {
return map.links.some(
(link) => (link.a === a && link.b === b) || (link.a === b && link.b === a),
);
}
function defaultSlots(def: RoomInfo): { key: string; thing: string }[] {
return def.slots.map((slot) => ({ key: slot.key, thing: slot.thing }));
}
function nextId(prefix: string, used: Set<string>): string {
let n = 1;
let candidate = `${prefix}-${n}`;
while (used.has(candidate)) {
n += 1;
candidate = `${prefix}-${n}`;
}
return candidate;
}
function slug(defName: string): string {
return defName
.replaceAll(/([a-z])([A-Z])/g, '$1-$2')
.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 roomName(catalog: CatalogResponse, room: MapLayout['rooms'][number]): string {
const def = defLabel(catalog.rooms, room.def);
const label = room.label ?? '';
if (def === undefined) {
return label.length > 0 ? label : room.id;
}
return label.length > 0 ? `${def} ${label}` : def;
}
function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined {
return defs.find((def) => def.defName === defName)?.label;
}
function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string {
if (map.territory.id === id) {
return defLabel(catalog.territories, map.territory.def) ?? id;
}
const building = map.buildings.find((node) => node.id === id);
if (building !== undefined) {
return defLabel(catalog.buildings, building.def) ?? id;
}
const floor = map.floors.find((node) => node.id === id);
if (floor !== undefined) {
return floorName(catalog, floor);
}
const room = map.rooms.find((node) => node.id === id);
if (room !== undefined) {
return roomName(catalog, room);
}
return id;
}
function selectOf(ids: readonly string[], catalog: CatalogResponse, map: MapLayout): HTMLSelectElement {
const select = el('select', { class: 'input' });
for (const id of ids) {
const option = el('option', { text: labelOf(catalog, map, id) });
option.value = id;
select.append(option);
}
return select;
}