mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
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();
|
||
},
|
||
};
|
||
}
|