Files
astrolabe/src/app/components/Icon.tsx
T
oleh 26b383ff33 Editor: render a layer as one framed row of mark glyphs
A layer is one plotting space with several marks stacked in z-order, so it now renders as a single frame holding its child marks as a row of glyphs, badged with a new 'layers' glyph — instead of offset stacked cards that read as separate spaces and hid the lower glyphs. Each mark stays an individual treeitem, so selection and Alt+up/down z-order reorder are unchanged.
2026-06-29 14:59:01 +03:00

343 lines
14 KiB
TypeScript
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.
/**
* Icon — the shared icon primitive and controlled vocabulary (arch 09 §5).
*
* Astrolabe is label-first: an icon is added only when it does real work, usually
* alongside text (arch 09 §5.1). This module is the single source of truth for the
* icon set — one glyph per meaning, app-wide (§5.2). Adding an icon means adding a
* `IconName` + a registry entry here, never inlining an SVG in a component.
*
* The glyphs are traced from IBM Carbon's icon set (32×32 grid, fill-based — they
* read as outlines but are filled shapes). Carbon is inspiration, not a dependency.
* Every glyph draws with `fill: currentColor`, so it inherits its text colour and
* themes for free.
*
* Icons are decorative by default (`aria-hidden`): the control around them carries
* the accessible name (text label, or an `aria-label` on an icon-only button — APG
* button pattern). Never rely on an icon alone to name a control.
*/
import type { ReactNode } from 'react';
import styles from './Icon.module.css';
/** The controlled icon vocabulary (arch 09 §5.2). One entry = one meaning. */
export type IconName =
| 'close' // close / dismiss (universal, icon-only)
| 'moon' // theme: switch to dark (universal, icon-only)
| 'sun' // theme: switch to light (universal, icon-only)
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
| 'chart' // build / open a chart — rising columns on a baseline (pane-icon style)
| 'search' // live library search — Carbon Search
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
| 'import' // import a workspace file — Carbon Upload (a file goes in)
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
| 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
| 'layers' // layered-composition badge (wireframe) — two stacked planes (one shared space)
// Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit
// view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms
// collapse onto these via `markIconName` (CompositionWireframe); unknown → generic.
| 'mark-bar'
| 'mark-line'
| 'mark-area'
| 'mark-point'
| 'mark-arc'
| 'mark-rect'
| 'mark-tick'
| 'mark-rule'
| 'mark-text'
| 'mark-generic'
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right).
| 'pane-library' // toggle the library pane (left)
| 'pane-editor' // toggle the editor pane (centre)
| 'pane-preview' // toggle the preview pane (right)
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
// triangle shape-codes warning apart from the round error/success/info.
| 'status-error' // Carbon ErrorFilled
| 'status-warning' // Carbon WarningAltFilled (triangle)
| 'status-success' // Carbon CheckmarkFilled
| 'status-info'; // Carbon InformationFilled
/** Carbon icon scale (arch 09 §5.3). 16px (sm) is the default, paired to 14px body. */
type IconSize = 'sm' | 'md' | 'lg' | 'xl';
/** Inner SVG geometry per glyph, on Carbon's 0 0 32 32 grid. fill comes from CSS. */
const GLYPHS: Record<IconName, ReactNode> = {
close: (
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
// Rising columns on a baseline — drawn in the pane-icon rect style (not a Carbon
// trace) so it reads at 16px next to the pane glyphs.
chart: (
<>
<rect x="4" y="24" width="24" height="2" />
<rect x="8" y="16" width="4" height="8" />
<rect x="14" y="10" width="4" height="14" />
<rect x="20" y="6" width="4" height="18" />
</>
),
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
<path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z" />
),
settings: (
<>
<path d="M27,16.76c0-.25,0-.5,0-.76s0-.51,0-.77l1.92-1.68A2,2,0,0,0,29.3,11L26.94,7a2,2,0,0,0-1.73-1,2,2,0,0,0-.64.1l-2.43.82a11.35,11.35,0,0,0-1.31-.75l-.51-2.52a2,2,0,0,0-2-1.61H13.64a2,2,0,0,0-2,1.61l-.51,2.52a11.48,11.48,0,0,0-1.32.75L7.43,6.06A2,2,0,0,0,6.79,6,2,2,0,0,0,5.06,7L2.7,11a2,2,0,0,0,.41,2.51L5,15.24c0,.25,0,.5,0,.76s0,.51,0,.77L3.11,18.45A2,2,0,0,0,2.7,21L5.06,25a2,2,0,0,0,1.73,1,2,2,0,0,0,.64-.1l2.43-.82a11.35,11.35,0,0,0,1.31.75l.51,2.52a2,2,0,0,0,2,1.61h4.72a2,2,0,0,0,2-1.61l.51-2.52a11.48,11.48,0,0,0,1.32-.75l2.42.82a2,2,0,0,0,.64.1,2,2,0,0,0,1.73-1L29.3,21a2,2,0,0,0-.41-2.51ZM25.21,24l-3.43-1.16a8.86,8.86,0,0,1-2.71,1.57L18.36,28H13.64l-.71-3.55a9.36,9.36,0,0,1-2.7-1.57L6.79,24,4.43,20l2.72-2.4a8.9,8.9,0,0,1,0-3.13L4.43,12,6.79,8l3.43,1.16a8.86,8.86,0,0,1,2.71-1.57L13.64,4h4.72l.71,3.55a9.36,9.36,0,0,1,2.7,1.57L25.21,8,27.57,12l-2.72,2.4a8.9,8.9,0,0,1,0,3.13L27.57,20Z" />
<path d="M16,22a6,6,0,1,1,6-6A5.94,5.94,0,0,1,16,22Zm0-10a3.91,3.91,0,0,0-4,4,3.91,3.91,0,0,0,4,4,3.91,3.91,0,0,0,4-4A3.91,3.91,0,0,0,16,12Z" />
</>
),
delete: (
<>
<rect x="12" y="12" width="2" height="12" />
<rect x="18" y="12" width="2" height="12" />
<path d="M4,6V8H6V28a2,2,0,0,0,2,2H24a2,2,0,0,0,2-2V8h2V6ZM8,28V8H24V28Z" />
<rect x="12" y="2" width="8" height="2" />
</>
),
// Carbon Upload — open tray with an up arrow (a file is brought into the app).
import: (
<>
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
<path d="M6,12l1.41,1.41L15,5.83V24h2V5.83l7.59,7.59L26,12,16,2Z" />
</>
),
// Carbon Download — open tray with a down arrow (a file is written out).
export: (
<>
<path d="M26,24v4H6V24H4v4a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V24Z" />
<path d="M26,14l-1.41-1.41L17,20.17V2H15V20.17l-7.59-7.58L6,14l10,10Z" />
</>
),
// Carbon Information (outline) — ring + "i".
info: (
<>
<path d="M17,22V14H13v2h2v6H12v2h8V22Z" />
<path d="M16,8a1.5,1.5,0,1,0,1.5,1.5A1.5,1.5,0,0,0,16,8Z" />
<path d="M16,30A14,14,0,1,1,30,16,14,14,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12,12,0,0,0,16,4Z" />
</>
),
// Carbon Reset — a circular arrow, "return to the prior state".
revert: (
<path d="M18,28A12,12,0,1,0,6,16v6.2L2.41,18.59,1,20l6,6,6-6L11.59,18.59,8,22.2V16A10,10,0,1,1,18,26Z" />
),
dataset: (
<>
<rect x="8" y="18" width="4" height="2" />
<rect x="14" y="18" width="4" height="2" />
<rect x="8" y="14" width="4" height="2" />
<rect x="14" y="22" width="4" height="2" />
<rect x="20" y="14" width="4" height="2" />
<rect x="20" y="22" width="4" height="2" />
<path d="M27,3H5A2.0025,2.0025,0,0,0,3,5V27a2.0025,2.0025,0,0,0,2,2H27a2.0025,2.0025,0,0,0,2-2V5A2.0025,2.0025,0,0,0,27,3Zm0,2,0,4H5V5ZM5,27V11H27l0,16Z" />
</>
),
// Panel frame (x428 / y626, 2px border) with one third filled. The filled bar's
// position maps to the pane: left = library, centre = editor, right = preview.
'pane-library': (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="7" y="10" width="4" height="12" />
</>
),
'pane-editor': (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="14" y="10" width="4" height="12" />
</>
),
'pane-preview': (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="21" y="10" width="4" height="12" />
</>
),
// Composition structure: a panel frame (matching the pane family) holding nested
// view blocks — two side by side over one wide — the wireframe in miniature.
structure: (
<>
<rect x="4" y="6" width="24" height="2" />
<rect x="4" y="24" width="24" height="2" />
<rect x="4" y="6" width="2" height="20" />
<rect x="26" y="6" width="2" height="20" />
<rect x="9" y="10" width="6" height="5" />
<rect x="17" y="10" width="6" height="5" />
<rect x="9" y="17" width="14" height="5" />
</>
),
// Two offset planes — a layer is one space with several marks stacked in z-order.
layers: (
<>
<rect x="6" y="6" width="15" height="15" fill="none" stroke="currentColor" strokeWidth={2} />
<rect
x="11"
y="11"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth={2}
/>
</>
),
// Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same
// 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic).
'mark-bar': (
<>
<rect x="5" y="14" width="5" height="14" />
<rect x="13" y="8" width="5" height="20" />
<rect x="21" y="18" width="5" height="10" />
</>
),
'mark-line': (
<polyline
points="4,22 11,13 18,17 28,6"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
'mark-area': <path d="M4,28 L4,17 L12,11 L20,16 L28,7 L28,28 Z" />,
'mark-point': (
<>
<circle cx="9" cy="20" r="2.6" />
<circle cx="16" cy="12" r="2.6" />
<circle cx="22" cy="22" r="2.6" />
<circle cx="25" cy="9" r="2.6" />
</>
),
// A three-quarter pie wedge (one quadrant empty) reads as arc/pie at a glance.
'mark-arc': <path d="M16,16 L16,4 A12,12 0 1,1 4,16 Z" />,
'mark-rect': (
<>
<rect x="5" y="9" width="6" height="6" />
<rect x="13" y="9" width="6" height="6" />
<rect x="21" y="9" width="6" height="6" />
<rect x="5" y="17" width="6" height="6" />
<rect x="13" y="17" width="6" height="6" />
<rect x="21" y="17" width="6" height="6" />
</>
),
'mark-tick': (
<>
<rect x="6" y="10" width="2" height="12" />
<rect x="12" y="10" width="2" height="12" />
<rect x="18" y="10" width="2" height="12" />
<rect x="24" y="10" width="2" height="12" />
</>
),
'mark-rule': (
<line
x1="5"
y1="24"
x2="27"
y2="8"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
/>
),
'mark-text': (
<>
<rect x="6" y="8" width="20" height="3" />
<rect x="6" y="15" width="14" height="3" />
<rect x="6" y="22" width="18" height="3" />
</>
),
'mark-generic': (
<rect x="5" y="6" width="22" height="20" fill="none" stroke="currentColor" strokeWidth={2} />
),
moon: (
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
),
sun: (
<>
<rect x="15" y="2" width="2" height="5" />
<rect
x="21.6675"
y="6.8536"
width="4.958"
height="1.9998"
transform="translate(1.5191 19.3744) rotate(-45)"
/>
<rect x="25" y="15" width="5" height="2" />
<rect
x="23.1466"
y="21.6675"
width="1.9998"
height="4.958"
transform="translate(-10.0018 24.1465) rotate(-45)"
/>
<rect x="15" y="25" width="2" height="5" />
<rect
x="5.3745"
y="23.1466"
width="4.958"
height="1.9998"
transform="translate(-14.7739 12.6256) rotate(-45)"
/>
<rect x="2" y="15" width="5" height="2" />
<rect
x="6.8536"
y="5.3745"
width="1.9998"
height="4.958"
transform="translate(-3.253 7.8535) rotate(-45)"
/>
<path d="M16,12a4,4,0,1,1-4,4,4.0045,4.0045,0,0,1,4-4m0-2a6,6,0,1,0,6,6,6,6,0,0,0-6-6Z" />
</>
),
// Filled status glyphs: the symbol is a winding-rule knockout in the solid shape,
// so it shows the surface colour through (Carbon's filled notification family).
'status-error': (
<path d="M16,2A13.914,13.914,0,0,0,2,16,13.914,13.914,0,0,0,16,30,13.914,13.914,0,0,0,30,16,13.914,13.914,0,0,0,16,2Zm5.4449,21L9,10.5557,10.5557,9,23,21.4448Z" />
),
'status-warning': (
<path d="M16.002,6.1714h-.004L4.6487,27.9966,4.6506,28H27.3494l.0019-.0034ZM14.875,12h2.25v9h-2.25ZM16,26a1.5,1.5,0,1,1,1.5-1.5A1.5,1.5,0,0,1,16,26Z" />
),
'status-success': (
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM14,21.5908l-5-5L10.5906,15,14,18.4092,21.41,11l1.5957,1.5859Z" />
),
'status-info': (
<path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,6a1.5,1.5,0,1,1-1.5,1.5A1.5,1.5,0,0,1,16,8Zm4,16.125H12v-2.25h2.875v-5.75H13v-2.25h4.125v8H20Z" />
),
};
export interface IconProps {
name: IconName;
/** Carbon scale; defaults to `sm` (16px) — the body-text default. */
size?: IconSize;
/** Extra class for layout-level overrides (margins, colour). */
className?: string;
}
export function Icon({ name, size = 'sm', className }: IconProps) {
return (
<svg
className={`${styles.icon} ${styles[size]}${className ? ` ${className}` : ''}`}
viewBox="0 0 32 32"
// Decorative: the enclosing control names itself (arch 09 §5.1).
aria-hidden="true"
focusable={false}
>
{GLYPHS[name]}
</svg>
);
}