376 lines
11 KiB
TypeScript
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;
|
|
}
|