framework-viz.js

  1/**
  2 * Periodic Table of Commands
  3 * Clean grid visualization showing all commands organized by category
  4 * Hover tooltips show description and relationships inline.
  5 */
  6
  7import { commandCategories, commandRelationships, betaCommands } from '../data.js';
  8
  9const categoryColors = {
 10	create: { bg: 'var(--cat-create-bg)', border: 'var(--cat-create-border)', text: 'var(--cat-create-text)' },
 11	evaluate: { bg: 'var(--cat-evaluate-bg)', border: 'var(--cat-evaluate-border)', text: 'var(--cat-evaluate-text)' },
 12	refine: { bg: 'var(--cat-refine-bg)', border: 'var(--cat-refine-border)', text: 'var(--cat-refine-text)' },
 13	simplify: { bg: 'var(--cat-simplify-bg)', border: 'var(--cat-simplify-border)', text: 'var(--cat-simplify-text)' },
 14	harden: { bg: 'var(--cat-harden-bg)', border: 'var(--cat-harden-border)', text: 'var(--cat-harden-text)' },
 15	system: { bg: 'var(--cat-system-bg)', border: 'var(--cat-system-border)', text: 'var(--cat-system-text)' }
 16};
 17
 18const categoryLabels = {
 19	create: 'Create',
 20	evaluate: 'Evaluate',
 21	refine: 'Refine',
 22	simplify: 'Simplify',
 23	harden: 'Harden',
 24	system: 'System'
 25};
 26
 27const commandSymbols = {
 28	'shape': 'Sh',
 29	'impeccable craft': 'Ic',
 30	'impeccable': 'Im',
 31	'overdrive': 'Od',
 32	'critique': 'Cr',
 33	'audit': 'Au',
 34	'typeset': 'Ty',
 35	'layout': 'La',
 36	'colorize': 'Co',
 37	'animate': 'An',
 38	'delight': 'De',
 39	'bolder': 'Bo',
 40	'quieter': 'Qu',
 41	'distill': 'Di',
 42	'clarify': 'Cl',
 43	'adapt': 'Ad',
 44	'polish': 'Po',
 45	'optimize': 'Op',
 46	'harden': 'Ha',
 47	'impeccable teach': 'It',
 48	'impeccable extract': 'Ie'
 49};
 50
 51const commandNumbers = {
 52	'shape': 0,
 53	'impeccable craft': 1, 'impeccable': 2, 'overdrive': 3,
 54	'critique': 4, 'audit': 5,
 55	'typeset': 6, 'layout': 7, 'colorize': 8, 'animate': 9,
 56	'delight': 10, 'bolder': 11, 'quieter': 12,
 57	'distill': 13, 'clarify': 14, 'adapt': 15,
 58	'polish': 16, 'optimize': 17, 'harden': 18,
 59	'impeccable teach': 19, 'impeccable extract': 20
 60};
 61
 62// Map sub-commands to their display label and scroll target
 63const commandDisplay = {
 64	'impeccable craft': { label: '/impeccable craft', scrollTo: 'impeccable' },
 65	'impeccable teach': { label: '/impeccable teach', scrollTo: 'impeccable' },
 66	'impeccable extract': { label: '/impeccable extract', scrollTo: 'impeccable' },
 67};
 68
 69export class PeriodicTable {
 70	constructor(container) {
 71		this.container = container;
 72		this.activeTooltip = null;
 73		this.activeElement = null;
 74		this.init();
 75	}
 76
 77	init() {
 78		this.container.innerHTML = '';
 79		this.container.style.cssText = `
 80			display: flex;
 81			flex-direction: column;
 82			gap: 16px;
 83			padding: 20px;
 84			height: 100%;
 85			box-sizing: border-box;
 86			position: relative;
 87		`;
 88
 89		this.renderTable();
 90	}
 91
 92	renderTable() {
 93		const groups = {};
 94		Object.entries(commandCategories).forEach(([cmd, cat]) => {
 95			if (!groups[cat]) groups[cat] = [];
 96			groups[cat].push(cmd);
 97		});
 98
 99		const categoryOrder = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
100
101		const grid = document.createElement('div');
102		grid.style.cssText = `
103			display: grid;
104			grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
105			gap: 16px;
106			flex: 1;
107		`;
108
109		categoryOrder.forEach(cat => {
110			const commands = groups[cat];
111			if (!commands) return;
112			const group = this.createCategoryGroup(cat, commands);
113			grid.appendChild(group);
114		});
115
116		this.container.appendChild(grid);
117	}
118
119	showTooltip(el, cmd) {
120		this.hideTooltip();
121
122		const rel = commandRelationships[cmd] || {};
123		const toArray = (val) => {
124			if (!val) return [];
125			if (Array.isArray(val)) return val;
126			return [val];
127		};
128
129		const pairs = toArray(rel.pairs);
130		const leadsTo = toArray(rel.leadsTo);
131		const combinesWith = toArray(rel.combinesWith);
132
133		// Build relationships line
134		let relParts = [];
135		if (pairs.length > 0) relParts.push(`pairs with ${pairs.map(p => '/' + p).join(', ')}`);
136		if (combinesWith.length > 0) relParts.push(`+ ${combinesWith.map(p => '/' + p).join(', ')}`);
137		if (leadsTo.length > 0) relParts.push(`then ${leadsTo.map(p => '/' + p).join(', ')}`);
138
139		// Strip category prefix from flow for cleaner display
140		const flow = (rel.flow || '').replace(/^[^:]+:\s*/, '');
141
142		const tooltip = document.createElement('div');
143		tooltip.className = 'ptable-tooltip';
144		tooltip.style.cssText = `
145			position: absolute;
146			z-index: 20;
147			background: var(--color-paper);
148			border: 1px solid var(--color-mist);
149			border-radius: 6px;
150			padding: 10px 14px;
151			box-shadow: 0 8px 24px -4px rgba(0,0,0,0.12);
152			pointer-events: none;
153			max-width: 280px;
154			opacity: 0;
155			transition: opacity 0.15s ease;
156		`;
157
158		tooltip.innerHTML = `
159			<div style="font-family: var(--font-body); font-size: 13px; color: var(--color-charcoal); line-height: 1.4; margin-bottom: ${relParts.length ? '6px' : '0'};">${flow}</div>
160			${relParts.length ? `<div style="font-family: var(--font-mono); font-size: 11px; color: var(--color-ash); line-height: 1.4;">${relParts.join(' · ')}</div>` : ''}
161		`;
162
163		this.container.appendChild(tooltip);
164
165		// Position relative to element
166		const elRect = el.getBoundingClientRect();
167		const containerRect = this.container.getBoundingClientRect();
168
169		const left = elRect.left - containerRect.left;
170		const top = elRect.bottom - containerRect.top + 6;
171
172		tooltip.style.left = `${Math.min(left, containerRect.width - 290)}px`;
173		tooltip.style.top = `${top}px`;
174
175		// Fade in
176		requestAnimationFrame(() => { tooltip.style.opacity = '1'; });
177
178		this.activeTooltip = tooltip;
179	}
180
181	hideTooltip() {
182		if (this.activeTooltip) {
183			this.activeTooltip.remove();
184			this.activeTooltip = null;
185		}
186	}
187
188	createCategoryGroup(category, commands) {
189		const colors = categoryColors[category];
190
191		const group = document.createElement('div');
192		group.style.cssText = `display: flex; flex-direction: column; gap: 6px;`;
193
194		const label = document.createElement('div');
195		label.style.cssText = `
196			font-family: var(--font-body);
197			font-size: 10px;
198			font-weight: 500;
199			text-transform: uppercase;
200			letter-spacing: 0.05em;
201			color: ${colors.text};
202			padding-left: 2px;
203		`;
204		label.textContent = categoryLabels[category];
205		group.appendChild(label);
206
207		const row = document.createElement('div');
208		row.style.cssText = `display: flex; flex-wrap: wrap; gap: 6px;`;
209
210		commands.forEach(cmd => {
211			const element = this.createElement(cmd, category);
212			row.appendChild(element);
213		});
214
215		group.appendChild(row);
216		return group;
217	}
218
219	createElement(cmd, category) {
220		const colors = categoryColors[category];
221		const display = commandDisplay[cmd];
222
223		const el = document.createElement('button');
224		el.type = 'button';
225		el.setAttribute('aria-label', `/${cmd} command - ${categoryLabels[category]}`);
226		el.style.cssText = `
227			width: 56px;
228			height: 64px;
229			background: ${colors.bg};
230			border: 1.5px solid ${colors.border};
231			border-radius: 5px;
232			display: flex;
233			flex-direction: column;
234			align-items: center;
235			justify-content: center;
236			cursor: pointer;
237			transition: transform 0.15s ease, box-shadow 0.15s ease;
238			position: relative;
239			font-family: inherit;
240			padding: 0;
241		`;
242
243		// Atomic number
244		const number = document.createElement('div');
245		number.style.cssText = `
246			position: absolute;
247			top: 3px;
248			left: 5px;
249			font-family: var(--font-mono);
250			font-size: 7px;
251			color: ${colors.text};
252			opacity: 0.5;
253		`;
254		number.textContent = commandNumbers[cmd];
255		el.appendChild(number);
256
257		// Symbol
258		const symbol = document.createElement('div');
259		symbol.style.cssText = `
260			font-family: var(--font-display);
261			font-size: 20px;
262			font-weight: 500;
263			color: ${colors.text};
264			line-height: 1;
265		`;
266		symbol.textContent = commandSymbols[cmd];
267		el.appendChild(symbol);
268
269		// Command name
270		const name = document.createElement('div');
271		name.style.cssText = `
272			font-family: var(--font-mono);
273			font-size: ${display ? '6.5px' : '8px'};
274			color: ${colors.text};
275			opacity: 0.7;
276			margin-top: 3px;
277			text-align: center;
278			max-width: 52px;
279			line-height: 1.3;
280			${display ? '' : 'white-space: nowrap;'}
281		`;
282		name.textContent = display ? display.label : `/${cmd}`;
283		el.appendChild(name);
284
285		// Beta badge
286		if (betaCommands.includes(cmd)) {
287			const badge = document.createElement('div');
288			badge.style.cssText = `
289				position: absolute;
290				top: 2px;
291				right: 3px;
292				font-family: var(--font-mono);
293				font-size: 5px;
294				letter-spacing: 0.05em;
295				color: ${colors.text};
296				opacity: 0.45;
297				text-transform: uppercase;
298			`;
299			badge.textContent = 'β';
300			el.appendChild(badge);
301		}
302
303		// Hover/focus: show tooltip
304		const activate = () => {
305			el.style.transform = 'translateY(-2px)';
306			el.style.boxShadow = `0 4px 12px ${colors.border}40`;
307			this.showTooltip(el, cmd);
308
309			if (this.activeElement && this.activeElement !== el) {
310				this.activeElement.style.transform = 'translateY(0)';
311				this.activeElement.style.boxShadow = 'none';
312			}
313			this.activeElement = el;
314		};
315
316		const deactivate = () => {
317			el.style.transform = 'translateY(0)';
318			el.style.boxShadow = 'none';
319			this.hideTooltip();
320		};
321
322		el.addEventListener('mouseenter', activate);
323		el.addEventListener('mouseleave', deactivate);
324		el.addEventListener('focus', activate);
325		el.addEventListener('blur', deactivate);
326
327		el.addEventListener('touchstart', (e) => {
328			e.preventDefault();
329			activate();
330		}, { passive: false });
331
332		el.addEventListener('click', () => {
333			activate();
334			const scrollTarget = display ? display.scrollTo : cmd;
335
336			// Navigate the fisheye scroller to this command
337			const fisheyeList = document.getElementById('fisheye-list');
338			if (fisheyeList) {
339				const items = [...fisheyeList.querySelectorAll('.fisheye-item')];
340				const idx = items.findIndex(item => item.dataset.id === scrollTarget);
341				if (idx >= 0 && fisheyeList._scrollToCommand) {
342					// Scroll the commands section into view first
343					const section = document.querySelector('.commands-subsection');
344					if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
345					fisheyeList._scrollToCommand(idx);
346					return;
347				}
348			}
349
350			// Fallback: scroll to the spread element
351			const target = document.getElementById(`cmd-${scrollTarget}`);
352			if (target) {
353				target.scrollIntoView({ behavior: 'smooth', block: 'center' });
354			}
355		});
356
357		return el;
358	}
359}
360
361export function initFrameworkViz() {
362	const container = document.getElementById('framework-viz-container');
363	if (container) {
364		new PeriodicTable(container);
365	}
366}