aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/pins.js
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/js/pins.js')
-rw-r--r--viewer/js/pins.js143
1 files changed, 143 insertions, 0 deletions
diff --git a/viewer/js/pins.js b/viewer/js/pins.js
new file mode 100644
index 0000000..4afc16b
--- /dev/null
+++ b/viewer/js/pins.js
@@ -0,0 +1,143 @@
+// pins.js — pin search and checks (pure) + the Pins panel (list, add by clicking the map, edit, delete)
+import { api, el, toast } from './ui.js';
+import { fmtLatLon } from './units.js';
+
+export function filterPins(pins, query) {
+ const q = query.trim().toLowerCase();
+ const hit = p => [p.name, p.lore, p.note].some(v => v && v.toLowerCase().includes(q));
+ return (q ? pins.filter(hit) : [...pins]).sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export const loreWarning = (lore, loreIds) =>
+ (lore && !loreIds.includes(lore) ? `“${lore}” is not a known lore id — saved anyway` : null);
+
+export const pinPayload = f => ({ name: f.name.trim(), lat: Number(f.lat), lon: Number(f.lon), lore: f.lore.trim() || null,
+ note: f.note.trim(), epoch: (f.epoch ?? '').trim() || null });
+export const epochIds = loreIds => loreIds.filter(id => /^epoch-[a-z0-9-]+$/.test(id));
+
+export class PinsPanel {
+ constructor(box, loreIds, { onPins, onFly, onPlacing, onDraft = () => {} }) {
+ Object.assign(this, { box, loreIds, onPins, onFly, onPlacing, onDraft });
+ this.pins = [];
+ this.query = '';
+ this.form = null; // {id?, name, lat, lon, lore, note} while adding or editing
+ this.placing = false;
+ }
+
+ async load() {
+ try {
+ this.pins = (await api('api/pins')).pins;
+ } catch (e) {
+ toast(`Pins: ${e.message}`);
+ }
+ this.onPins(this.pins);
+ this.render();
+ }
+
+ setPlacing(on) {
+ this.placing = on;
+ this.onPlacing(on);
+ this.render();
+ }
+
+ placeAt(p) {
+ this.placing = false;
+ this.onPlacing(false);
+ this.form = { name: '', lat: p.lat, lon: p.lon, lore: '', note: '', epoch: '' };
+ this.onDraft({ lat: p.lat, lon: p.lon });
+ this.render();
+ }
+
+ closeForm() {
+ this.form = null;
+ this.onDraft(null);
+ this.render();
+ }
+
+ edit(pin) {
+ this.form = { ...pin, lore: pin.lore || '', note: pin.note || '', epoch: pin.epoch || '' };
+ this.render();
+ }
+
+ async save() {
+ const f = this.form;
+ try {
+ if (f.id) await api(`api/pins/${f.id}`, { method: 'PUT', body: pinPayload(f) });
+ else await api('api/pins', { method: 'POST', body: pinPayload(f) });
+ this.closeForm();
+ await this.load();
+ } catch (e) {
+ toast(`Could not save the pin: ${e.message}`);
+ }
+ }
+
+ async remove(pin) {
+ if (!confirm(`Delete pin “${pin.name}”?`)) return;
+ try {
+ await api(`api/pins/${pin.id}`, { method: 'DELETE' });
+ await this.load();
+ } catch (e) {
+ toast(`Could not delete the pin: ${e.message}`);
+ }
+ }
+
+ render() {
+ this.box.replaceChildren(...(this.form ? this.#formView() : this.#listView()));
+ }
+
+ #listView() {
+ const list = el('div');
+ const search = el('input', { type: 'search', placeholder: 'Search pins', value: this.query });
+ search.addEventListener('input', () => { this.query = search.value; this.#fill(list); });
+ this.#fill(list);
+ return [
+ el('div', {}, el('button', { class: this.placing ? 'on' : null, onclick: () => this.setPlacing(!this.placing) },
+ this.placing ? 'Click the map… (Esc cancels)' : '+ Add pin'), ' ', search),
+ list,
+ ];
+ }
+
+ #fill(list) {
+ const item = p => el('div', { class: 'pin-item', onclick: () => this.onFly(p) },
+ el('b', {}, p.name), p.lore ? el('span', { class: 'muted' }, ` · ${p.lore}`) : null,
+ p.epoch ? el('span', { class: 'muted' }, ` · ${p.epoch.replace(/^epoch-/, '')}`) : null, el('br'),
+ el('span', { class: 'muted' }, fmtLatLon(p.lat, p.lon)), ' ',
+ el('button', { onclick: e => { e.stopPropagation(); this.edit(p); } }, 'Edit'), ' ',
+ el('button', { onclick: e => { e.stopPropagation(); this.remove(p); } }, 'Delete'),
+ loreWarning(p.lore, this.loreIds) ? el('div', { class: 'warn' }, loreWarning(p.lore, this.loreIds)) : null);
+ const shown = filterPins(this.pins, this.query);
+ list.replaceChildren(...(shown.length ? shown.map(item)
+ : [el('p', { class: 'muted' }, this.pins.length ? 'No pins match.' : 'No pins yet.')]));
+ }
+
+ #formView() {
+ const f = this.form;
+ const warn = el('div', { class: 'warn' }, loreWarning(f.lore.trim(), this.loreIds) ?? '');
+ const field = (key, tag, attrs, ...kids) => {
+ const i = el(tag, attrs, ...kids);
+ i.value = f[key];
+ i.addEventListener('input', () => {
+ f[key] = i.value;
+ if (key === 'lore') warn.textContent = loreWarning(f.lore.trim(), this.loreIds) ?? '';
+ });
+ return i;
+ };
+ const name = field('name', 'input', { required: true, maxlength: 120 });
+ queueMicrotask(() => name.focus());
+ const form = el('form', { class: 'pin' },
+ el('p', { class: 'muted' }, f.id ? `Edit ${f.id}` : 'New pin', ' · ', fmtLatLon(f.lat, f.lon)),
+ el('label', {}, 'Name', name),
+ el('label', {}, 'Lore id (optional)', field('lore', 'input', { list: 'lore-ids', placeholder: 'kebab-case id from lore/' })),
+ warn,
+ el('datalist', { id: 'lore-ids' }, this.loreIds.map(id => el('option', { value: id }))),
+ el('label', {}, 'Note (optional)', field('note', 'textarea', { rows: 4, maxlength: 2000 })),
+ el('label', {}, 'Epoch (optional)', field('epoch', 'select', {},
+ el('option', { value: '' }, '— any —'), ...epochIds(this.loreIds).map(id => el('option', { value: id }, id.replace(/^epoch-/, ''))))),
+ el('div', { class: 'coords' }, el('label', {}, 'Lat', field('lat', 'input', { type: 'number', step: 'any', min: -90, max: 90 })),
+ el('label', {}, 'Lon', field('lon', 'input', { type: 'number', step: 'any', min: -180, max: 180 }))),
+ el('div', {}, el('button', { type: 'submit' }, 'Save'), ' ',
+ el('button', { type: 'button', onclick: () => this.closeForm() }, 'Cancel')));
+ form.addEventListener('submit', e => { e.preventDefault(); this.save(); });
+ return [form];
+ }
+}