Files
Tectum/js/core/ui-kit.js
T

209 lines
11 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Labels: zelfstandig naamwoord met hoofdletter, geen dubbele punt, uitleg in een hint, eenheid rechts.
window.UI = (() => {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const icon = (name, cls) => `<i class="bi bi-${name}${cls ? ' ' + cls : ''}" aria-hidden="true"></i>`;
const hint = (text) => text ? `<span class="ui-hint" title="${esc(text)}">${icon('info-circle')}</span>` : '';
function field(id, label, control, o) {
return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`;
}
function num(id, label, value, o) {
o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit;
return field(id, label,
`<input type="number" id="${id}" value="${value}" step="${o.step || 50}"${o.min !== undefined ? ` min="${o.min}"` : ''}${o.disabled ? ' disabled' : ''}>` +
`<span class="ui-unit">${unit}</span>`, o);
}
// Het getal mag buiten het schuifbereik; het model klemt zelf.
function range(id, label, value, o) {
o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit;
const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`;
return `<div class="ui-field ui-field-range${o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o.hint)}</label>` +
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
}
function onRange(id, fn, after) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (!n || !r) return;
r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); });
n.addEventListener('input', () => {
const v = parseFloat(n.value);
if (!isFinite(v)) return;
r.value = v;
fn(v); after && after();
});
}
function setRange(id, v) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (n && document.activeElement !== n) n.value = v;
if (r) r.value = v;
}
function sel(id, label, options, value, o) {
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o);
}
function color(id, label, value, o) {
return field(id, label, `<input type="color" id="${id}" value="${value}"><span class="ui-unit"></span>`, o);
}
function onColor(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('change', (e) => { fn(e.target.value); after && after(); });
}
function text(id, label, value, o) {
return field(id, label, `<input type="text" id="${id}" value="${esc(value || '')}"${o && o.placeholder ? ` placeholder="${esc(o.placeholder)}"` : ''}><span class="ui-unit"></span>`, o);
}
function onText(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
}
function toggle(id, label, checked, o) {
return field(id, label,
`<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o);
}
const section = (title, iconName, body) =>
`<section class="ui-section"><h2>${iconName ? icon(iconName) : ''}${title}</h2>${body}</section>`;
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
const btnOf = (base) => (label, iconName, attrs, cls) =>
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
const button = btnOf('ui-btn');
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
const activeTabs = {};
function tabs(group, list) {
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
return `<div class="ui-tabs" data-tab-group="${group}">
<div class="ui-tab-bar">${list.map(t => `<button type="button" class="ui-tab${t.key === active ? ' active' : ''}" data-tab="${t.key}">${t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('')}</div>
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
</div>`;
}
function wireTabs(container) {
if (container._uiTabsWired) return;
container._uiTabsWired = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('.ui-tab');
if (!btn) return;
const wrap = btn.closest('.ui-tabs');
activeTabs[wrap.dataset.tabGroup] = btn.dataset.tab;
wrap.querySelectorAll('.ui-tab').forEach(b => b.classList.toggle('active', b === btn));
wrap.querySelectorAll('.ui-tab-page').forEach(p => p.classList.toggle('active', p.dataset.tabPage === btn.dataset.tab));
});
}
function specCard(title, iconName, rows, total) {
const row = ([label, value, unit]) =>
`<div class="ui-spec-row"><span>${label}</span><span class="ui-spec-val">${value}${unit ? `<small>${unit}</small>` : ''}</span></div>`;
return `<div class="ui-spec-card"><h2>${icon(iconName)}${title}</h2>${rows.map(row).join('')}${total ? `<div class="ui-spec-total">${row(total)}</div>` : ''}</div>`;
}
function onNum(id, fn, after, o) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v) && (!(o && o.positive) || v > 0)) { fn(v); after && after(); }
});
}
function onSel(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
}
function onToggle(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
}
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
const VIEWS = [
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
];
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
function setView(el, mode) {
el.classList.toggle('fullscreen-3d', mode === '3d');
el.classList.toggle('fullscreen-2d', mode === '2d');
el.classList.remove('render-only');
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
}
const tb = btnOf('tb-btn');
const tbSep = () => '<span class="tb-sep"></span>';
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
function editorBar(toolbar, root, o) {
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
const group = toolbar.querySelector('.tb-view');
if (o.layers) ViewLayers.createMenu(group, o.layers);
if (o.onSky) Skybox.createToggle(group, o.onSky);
const bar = {
setView(mode) {
if (o.beforeView) o.beforeView(mode);
setView(root, mode);
requestAnimationFrame(o.onResize);
},
viewState: () => ({ view: viewOf(root) })
};
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
return bar;
}
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
let openPop = null;
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
function release(close) { if (openPop === close) openPop = null; }
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
document.addEventListener('click', () => closePopovers());
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
function popover(btn, panel) {
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.classList.contains('open')) { close(); return; }
claim(close);
panel.classList.add('open');
btn.classList.add('open');
});
panel.addEventListener('click', (e) => e.stopPropagation());
return close;
}
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
function masonry(el, minW, gap) {
if (!el) return;
gap = gap === undefined ? 12 : gap;
const cards = el._cards || (el._cards = [...el.children]);
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
if (el._cols === n) return;
el._cols = n;
el.classList.add('ui-masonry');
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
el.replaceChildren(...cols);
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
}
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
})();