Files
gods-eye-view/src/ui/railCardBlocks.js
T

399 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
const order = (parent, nodes) =>
nodes.forEach((node, i) => {
if (parent.children[i] !== node)
parent.insertBefore(node, parent.children[i] || null);
});
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
const blocks = new Map();
const make = (tag, className, parent) => {
const node = document.createElement(tag);
node.className = className;
parent.appendChild(node);
return node;
};
const dispatch = (props) => {
if (!props || props.disabled) return;
if (props.params) onParams(cardId, props.params);
else props.onClick?.();
};
const dispose = (block) => {
block.removeClick?.();
block.removeInput?.();
block.children?.destroy();
block.root.remove();
};
return {
update(descriptors = []) {
const ids = new Set();
for (const props of descriptors) {
ids.add(props.id);
let block = blocks.get(props.id);
if (block && block.type !== props.type) {
dispose(block);
blocks.delete(props.id);
block = null;
}
if (!block) {
const root = make(
props.type === 'list'
? 'ol'
: props.type === 'actions'
? 'footer'
: 'div',
props.type === 'list' ? 'data-row-list' : `rail-card-${props.type}`,
container,
);
root.dataset.blockId = props.id;
block = { root, type: props.type, props };
const bind = (fn) => {
root.addEventListener('click', fn);
block.removeClick = () => root.removeEventListener('click', fn);
};
if (props.type === 'settings') {
block.children = createRailCardBlocks({
container: root,
cardId,
onParams,
});
} else if (props.type === 'setting') {
block.label = make(
'span',
'rail-card-setting-label panel-title',
root,
);
block.chips = make('div', 'rail-card-chips', root);
block.chips.setAttribute('role', 'group');
block.rangeRow = make('div', 'rail-card-setting-range-row', root);
block.rangeLabel = make(
'span',
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = make(
'input',
'rail-card-setting-range',
block.rangeRow,
);
block.rangeInput.type = 'range';
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
block.rangeRow,
);
const rangeParams = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
if (
!range?.param ||
range.disabled ||
!Number.isFinite(value) ||
value < Number(range.min) ||
value > Number(range.max)
)
return null;
return { params: { [range.param]: value } };
};
const onInput = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
const params = rangeParams();
if (!params) return;
if (range.commitOnChange) block.rangeDraft = { range, value };
set(
block.rangeValue,
'textContent',
`${value}${range.unit || ''}`,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
if (!range.commitOnChange) dispatch(params);
};
const onChange = () => {
if (!block.props.range?.commitOnChange) return;
const params = rangeParams();
block.rangeDraft = null;
if (params) dispatch(params);
};
block.rangeInput.addEventListener('input', onInput);
block.rangeInput.addEventListener('change', onChange);
block.removeInput = () => {
block.rangeInput.removeEventListener('input', onInput);
block.rangeInput.removeEventListener('change', onChange);
};
bind((event) => {
const button = event.target?.closest?.('[data-chip-id]');
dispatch(
block.props.chips?.find(
({ id }) => id === button?.dataset.chipId,
),
);
});
} else if (props.type === 'list') {
bind((event) => {
const button = event.target?.closest?.('.data-row-list-item');
dispatch(
block.props.list?.items?.find(
({ id }) => id === button?.dataset.listItemId,
),
);
});
} else if (props.type === 'result') {
block.header = make('div', 'rail-card-result-header', root);
block.label = make('span', 'panel-title', block.header);
block.clear = make('button', 'data-toggle-chip', block.header);
block.clear.type = 'button';
block.clear.dataset.actionId = 'clear';
block.clear.textContent = '×';
block.clear.setAttribute('aria-label', 'Clear reading');
block.clear.title = 'Clear reading';
bind((event) => {
if (event.target === block.clear) dispatch(block.props.clear);
});
block.children = createRailCardBlocks({
container: make('div', 'rail-card-result-lines', root),
cardId,
onParams,
});
} else if (props.type === 'legend') {
block.ramp = make('div', 'rail-card-ramp', root);
block.zero = make('span', 'rail-card-zero', block.ramp);
block.zero.setAttribute('aria-hidden', 'true');
block.scale = make('div', 'rail-card-scale', root);
block.entries = make('div', 'rail-card-legend-entries', root);
} else if (props.type === 'actions') {
block.actions = new Map();
bind((event) => {
for (const action of block.actions.values())
if (event.target === action.node) dispatch(action.props);
});
} else block.lines = new Map();
blocks.set(props.id, block);
}
block.props = props;
if (props.type === 'settings') {
block.children.update(
(props.settings || []).map((setting) => ({
...setting,
type: 'setting',
})),
);
} else if (props.type === 'setting') {
set(block.label, 'textContent', props.label);
attribute(block.chips, 'aria-label', props.label);
syncChipGroup(block.chips, props.chips);
const range = props.range;
set(block.rangeRow, 'hidden', !range);
const draft = block.rangeDraft;
if (
draft &&
(!range ||
range.disabled ||
!range.commitOnChange ||
['id', 'param', 'min', 'max', 'step', 'value'].some(
(key) => range[key] !== draft.range[key],
))
)
block.rangeDraft = null;
if (range) {
const min = Number(range.min);
const max = Number(range.max);
const step = Number(range.step);
const candidate = Number(block.rangeDraft?.value ?? range.value);
const value = Math.min(
max,
Math.max(min, Number.isFinite(candidate) ? candidate : min),
);
set(block.rangeLabel, 'textContent', range.label || '');
set(block.rangeLabel, 'hidden', !range.label);
set(block.rangeInput.dataset, 'rangeId', range.id || props.id);
set(block.rangeInput, 'min', String(min));
set(block.rangeInput, 'max', String(max));
set(block.rangeInput, 'step', String(step));
set(block.rangeInput, 'value', String(value));
set(block.rangeInput, 'disabled', Boolean(range.disabled));
attribute(
block.rangeInput,
'aria-label',
range.ariaLabel || props.label,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`);
}
} else if (props.type === 'list') syncRowList(block.root, props.list);
else if (props.type === 'result') {
set(block.label, 'textContent', props.label);
set(block.clear, 'hidden', !props.clear);
block.children.update([
{ id: 'result-lines', type: 'lines', lines: props.lines },
]);
} else if (props.type === 'legend') {
const legend = props.legend;
const colors = (legend?.colors || []).filter((color) =>
/^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color),
);
const visible =
colors.length >= 2 && colors.length === legend.colors.length;
set(block.root, 'hidden', !visible);
set(block.ramp, 'hidden', !visible || Boolean(legend?.categorical));
set(block.scale, 'hidden', Boolean(legend?.categorical));
set(block.entries, 'hidden', !legend?.categorical);
if (legend?.categorical) {
while (block.entries.children.length > colors.length)
block.entries.children[
block.entries.children.length - 1
].remove();
colors.forEach((color, index) => {
let entry = block.entries.children[index];
if (!entry) {
entry = make('div', 'rail-card-legend-entry', block.entries);
make('span', 'rail-card-legend-swatch', entry);
make('span', '', entry);
}
if (entry.dataset.color !== color) {
entry.children[0].style.background = color;
entry.dataset.color = color;
}
set(entry.children[1], 'textContent', legend.labels[index] || '');
});
}
const gradient = visible
? `linear-gradient(to right, ${colors.join(',')})`
: '';
if (block.gradient !== gradient) {
block.ramp.style.background = gradient;
block.gradient = gradient;
}
const zeroVisible =
visible &&
legend.units === '°C' &&
Number.isInteger(legend.zeroIndex) &&
legend.zeroIndex >= 0 &&
legend.zeroIndex < colors.length;
set(block.zero, 'hidden', !zeroVisible);
if (zeroVisible) {
const left = `${(legend.zeroIndex / (colors.length - 1)) * 100}%`;
if (block.zeroLeft !== left) {
block.zero.style.left = left;
block.zeroLeft = left;
}
}
set(
block.scale,
'textContent',
visible
? `${legend.labels[0]} — ${legend.labels.at(-1)} ${legend.units || ''}`.trim()
: '',
);
} else if (props.type === 'actions') {
const actions = props.actions || [];
for (const [id, action] of block.actions)
if (
!actions.some(
(props) =>
props.id === id &&
(props.href ? 'a' : 'button') === action.tag,
)
) {
action.node.remove();
action.hint.remove();
block.actions.delete(id);
}
for (const actionProps of actions) {
let action = block.actions.get(actionProps.id);
if (!action) {
const tag = actionProps.href ? 'a' : 'button';
const node = make(tag, 'data-toggle-chip', block.root);
node.dataset.actionId = actionProps.id;
if (tag === 'button') node.type = 'button';
else {
node.target = '_blank';
node.rel = 'noopener';
}
action = {
node,
tag,
hint: make('span', 'rail-card-action-hint', block.root),
};
block.actions.set(actionProps.id, action);
}
action.props = actionProps;
set(action.node, 'textContent', actionProps.label);
set(action.node, 'title', actionProps.title || '');
set(action.node, 'disabled', Boolean(actionProps.disabled));
if (action.tag === 'a' && action.href !== actionProps.href) {
action.node.href = actionProps.href;
action.href = actionProps.href;
}
set(action.hint, 'textContent', actionProps.hint || '');
set(action.hint, 'hidden', !actionProps.hint);
}
order(
block.root,
actions.flatMap(({ id }) => {
const action = block.actions.get(id);
return [action.node, action.hint];
}),
);
} else {
const lines = props.lines || [{ id: props.id, text: props.text }];
for (const [id, node] of block.lines)
if (!lines.some((line) => line.id === id)) {
node.remove();
block.lines.delete(id);
}
for (const line of lines) {
let node = block.lines.get(line.id);
if (!node) {
node = make('div', 'rail-card-line', block.root);
node.dataset.lineId = line.id;
block.lines.set(line.id, node);
}
set(node, 'textContent', String(line.text ?? ''));
set(
node,
'className',
`rail-card-line${line.muted ? ' muted' : ''}`,
);
}
order(
block.root,
lines.map(({ id }) => block.lines.get(id)),
);
}
}
for (const [id, block] of blocks)
if (!ids.has(id)) {
dispose(block);
blocks.delete(id);
}
order(
container,
descriptors.map(({ id }) => blocks.get(id).root),
);
},
destroy() {
for (const block of blocks.values()) dispose(block);
blocks.clear();
},
};
}