20. Виджеты
Виджет определяет, как модуль отрисовывается на диаграмме. Каждый модуль отрисовывается через какой-нибудь виджет — когда ничего не настроено, управление берёт на себя встроенный элемент Viewer arch-module. Эта глава охватывает два сценария:
- Настройка виджета по умолчанию — вы остаётесь с
arch-moduleи подкручиваете его внешний вид через поляwidget.*. Покрывает 80% случаев. - Сборка собственного виджета — регистрация своего пользовательского элемента, когда виджета по умолчанию недостаточно.
Оба пути используют одну и ту же поверхность поля widget: — парсер просто различает их по типу значения (идентификатор или строка).
Правило. Виджет меняет то, как модуль отрисовывается, но никогда — то, что он означает. Источник истины — модель, а диаграмма выводится из неё, поэтому оптимизируйте
.archпод точное и полное описание, а о внешнем виде пусть заботится рендерер. Берите виджет только чтобы яснее представить эту истину, а не чтобы компенсировать модель, которая говорит не то.
Настройка виджета по умолчанию
Заголовок раздела «Настройка виджета по умолчанию»Виджет по умолчанию arch-module настраивается через поля widget.* — а записи widget.* — это обычные поля: структурированные данные на модуле. В этом весь механизм. Именно поля управляют элементами управления виджета; вы подстраиваете карточку, задавая данные, а не рисуя. Полная поверхность настройки:
| Поле | Эффект |
|---|---|
widget.icon | Идентификатор иконки (одна из иконок стандартной библиотеки: service, database, browser, cluster и т. д.) |
widget.color | Акцентный цвет — имя из палитры (crimson, info), литеральный CSS-цвет или приведение (colorize(@@team)) |
widget.bg | Цвет фона или значение CSS background-image |
widget.subheader | Текст под именем — литерал или геттер, читающий поле/аспект (@@team) |
widget.footer | Текст нижней полосы — литерал или геттер |
widget.label1, widget.label2 | Значения, отрисованные как чипы ключ: значение в теле — обычно геттеры, чей ключ подписывает чип |
widget.width, widget.height | Размер карточки в пикселях модели (по умолчанию 220 × 140) |
Эти поля принимают геттер, когда должны прочитать значение с элемента: @@team — по оси аспектов, @repo.url — по оси полей (глава 9). Именно сигил помечает чтение — голое слово остаётся литералом, поэтому subheader: team напечатает слово «team». Геттеры разрешаются на каждом элементе, после распространения, поэтому одна строка на типе читает собственное значение каждого потомка.
Примечание. Вывод аспекта в виде чипа здесь — это просто оформление: статический показатель, напечатанный на карточке. Это не то же самое, что оверлей аспекта. Инфраструктурные планы — брокер сообщений, сетевая зона, логи, метрики — живут на собственном плане и раскрываются по требованию как переключаемый оверлей поверх всей доски, выведенный из аспектов, которые элементы разделяют, а не вшитый в какой-то один виджет. Виджет печатает значение аспекта; переключение раскладки аспекта показывает, кто его разделяет. См. главу 9 и главу 8.
Стандартная библиотека комплектует группу cascade * на каждом типе, чтобы авторы получали разумные значения по умолчанию из коробки. Например, service из стандартной библиотеки каскадирует два необязательных аспекта членства (team, domain) и конфигурацию виджета, которая считывает их для отображения:
export type module service { "A network-addressable backend service — the default building block of a microservice architecture" aspect team aspect domain cascade * widget: arch-module { icon: service color: info subheader: @@team footer: @@domain }}Ни один из аспектов не required — сервис, который ещё не зафиксировал команду или домен, всё равно валиден. Обязательность владения — это опциональная проверка политики, а не обязательное поле: governance обеспечивает её отдельно через блок policy (см. главу 28). Каждый экземпляр сервиса наследует группу виджета. Чтобы подкрутить один лист, переопределите этот лист на экземпляре:
service Orders { widget.color: green}Чтобы полностью заменить виджет (переключиться на пользовательский элемент), задайте корень:
service Orders { widget: my-orders-card}Поскольку родитель объявляет cascade *, вся группа (включая widget.icon, widget.color и т. д.) выбрасывается из наследования — никакой церемонии drop по каждому листу не нужно. Новый виджет начинает с чистого листа. Правила каскадных групп см. в главе 18.
Сборка собственного виджета
Заголовок раздела «Сборка собственного виджета»Правило. Простой модуль с простым интерфейсом — это значение по умолчанию; типизация модуля — синтаксический сахар. Пользовательский виджет — одна из немногих вещей, которые действительно оправдывают пользовательский тип (наряду с пользовательскими полями, пользовательскими требованиями и подтипизацией). Определяйте тип, чтобы дать семейству модулей собственный рендерер; не выдумывайте тип без поведения только ради обёртки над простым модулем. См. главу 15.
Две причины написать пользовательский элемент:
- Виджет по умолчанию не может представить вашу визуализацию — вам нужен цилиндр базы данных, человечек-актор, рендерер в форме таблицы, где каждая строка — интерфейс, и т. п.
- Вам нужно поведение, которое виджет по умолчанию не предоставляет — анимация, детализация при наведении, встроенные графики, живая загрузка данных.
Скелет:
class MyWidget extends HTMLElement { static get observedAttributes() { return ["name"]; }
connectedCallback() { const root = this.attachShadow({ mode: "open" }); root.innerHTML = ` <style>:host { display: block; width: 100%; height: 100%; }</style> <div class="card"></div> `; this.render(); }
attributeChangedCallback() { this.render(); }
// Custom property — the renderer assigns `el.arch = module` AFTER // connectedCallback fires. Trigger a re-render on assignment. set arch(value) { this._arch = value; this.render(); } get arch() { return this._arch; }
render() { const root = this.shadowRoot; if (!root) return; const name = this.getAttribute("name") ?? this._arch?.name ?? ""; root.querySelector(".card").textContent = name; }}customElements.define("my-widget", MyWidget);Зарегистрируйте элемент, перечислив его JS-файл в поле widgets: вашего пакета — см. главу 12.
Используйте его из модуля:
service Orders { widget: my-widget}Рендерер выдаёт <my-widget name="Orders" ...> для этого узла.
Пространства имён тегов виджетов
Заголовок раздела «Пространства имён тегов виджетов»Пользовательские элементы живут в одном глобальном реестре, поэтому два
пакета, которые оба регистрируют arch-card, молча столкнутся. Чтобы теги
было безопасно писать, у каждого пакета есть неявное пространство имён,
выведенное из его имени, и голая ссылка на виджет автоматически
получает этот префикс:
| Пакет | Голая ссылка | Разрешается в |
|---|---|---|
arch.c4 | widget: boundary | arch-c4-boundary |
myorg.widgets | widget: card | myorg-widgets-card |
Правило намеренно простое:
- Голому односложному имени (без дефиса) приставляется префикс объявляющего пакета.
- Имя, содержащее дефис, берётся как полный тег и используется
дословно — именно так вы ссылаетесь на встроенный (
arch-module) или виджет другого пакета (arch-ui-card).
Так что вы поставляете customElements.define("arch-c4-boundary", …) в
своём widgets.js и пишете короткое widget: boundary в .arch;
резолвер связывает одно с другим. Анонимные пакеты (без манифеста) не
имеют префикса, поэтому их голые имена остаются как есть.
Что получает виджет
Заголовок раздела «Что получает виджет»| Поверхность | Содержимое |
|---|---|
Атрибут name | Имя экземпляра |
Атрибут data-has-subspace | "true", когда у модуля есть дети; виджет может ветвиться на этом ради оформления контейнера |
Атрибут data-selected | "true", когда это выбранный модуль |
Атрибут data-highlighted | "true", когда узел находится в активном радиусе воздействия |
Атрибут data-hovered | "true" / "false", управляется слоем React (используйте вместо :hover, чтобы избежать особенностей “залипающего” hover в Chrome на GPU-слоях) |
Атрибут data-module-id | Стабильный идентификатор модуля (когда присутствует) — используется DOM-поисками рендерера |
Атрибуты widget.<…>-свойств | Каждое свойство widget.* в kebab-case — значение приведено к строке |
CSS-переменная --arch-projected-width | Спроецированная ширина в пикселях (непрерывная) |
CSS-переменная --arch-scale | Масштаб области просмотра (1 = пространство модели) |
CSS-переменные --arch-band-compact, --arch-band-compact-reveal | 0→1 по мере входа узла в compact-читаемый зум (фаза структуры и фаза раскрытия) |
CSS-переменные --arch-band-full, --arch-band-full-reveal | 0→1 по мере входа узла в зум полной детализации |
Свойство el.arch | Полный разрешённый модуль: { id, name, kindName, description, fields, aspects, surfaces, interfaces, children, sourceFile, … } |
Свойство el.archZoom | { projectedWidth, scale } |
Свойство el.archState | { selected, highlighted, hovered } |
Свойство el.archConnections (опционально) | { incoming, outgoing }, если установлено рендерером |
DOM-свойства устанавливаются после connectedCallback. Паттерн из скелета выше (сеттер запускает перерисовку) покрывает это.
Размер узла — за него отвечает виджет
Заголовок раздела «Размер узла — за него отвечает виджет»Решатель раскладки не читает widget.width / widget.height напрямую с экземпляра. Он спрашивает виджет. Виджеты выбирают между тремя политиками:
-
Настраиваемая — читайте
props.get("width")/props.get("height")вsizeForи откатывайтесь к собственному значению по умолчанию:class CustomCard extends HTMLElement {static sizeFor(props) {const w = props.get("width");const h = props.get("height");return {w: typeof w === "number" ? w : 220,h: typeof h === "number" ? h : 140,};}} -
Фиксированная — объявите
static archWidth/static archHeight(и пропуститеsizeFor). Экземплярноеwidget.width: 999не имеет эффекта.class Actor extends HTMLElement {static archWidth = 110;static archHeight = 140;} -
Вычисляемая — выведите размер из модели.
sizeForполучает(props, ctx), гдеctx = { kindName, interfaces, aspects }. Чистая, детерминированная — одни и те же входы всегда дают один и тот же размер, поэтому решателю не приходится перемерять.class DbTable extends HTMLElement {static archWidth = 240;static rowH = 22;static headerH = 44;static sizeFor(_props, ctx) {const rows = Math.min(ctx.interfaces.length, 32);return { h: this.headerH + rows * this.rowH };}}
Порядок разрешения по каждой оси: sizeFor(props, ctx) → static archWidth/Height → 0 (затем решатель использует свои глобальные значения по умолчанию NODE_W/NODE_H).
Отрисовка с учётом масштаба
Заголовок раздела «Отрисовка с учётом масштаба»Рендерер предоставляет масштаб через непрерывные CSS-переменные, которые плавно нарастают при прокрутке колеса/щипке пользователя. Привязывайте к ним раскладку и раскрытие:
[header] { flex: 1 0 auto; min-height: 1.6em; }[body] { flex: calc(999 * var(--arch-band-compact, 0)) 0 0; min-height: 0; }[footer] { max-height: calc(1.6em * var(--arch-band-full, 0)); opacity: var(--arch-band-full-reveal, 0); }--arch-band-compact нарастает 0→1 вокруг диапазона компактного зума (по умолчанию 0.52–0.56 масштаба области просмотра); --arch-band-full нарастает позже, вокруг 1.09–1.13. Соответствующие варианты *-reveal нарастают чуть позже фазы структуры, чтобы раскладка успела устояться до того, как проявится содержимое. Когда рост тела привязан к band-compact, тело схлопывается на зуме-точке, а заголовок поглощает оставшееся место.
Для размера шрифта контейнерные запросы чище, чем сырые сигналы полос: объявите заголовок как container-type: size; container-name: my-header; и используйте внутри единицы cqh/cqw.
Проекция подпространства
Заголовок раздела «Проекция подпространства»Когда у модуля есть дети, рендерер монтирует подпространство как дочерний элемент light-DOM хоста виджета. Виджет проецирует его через свой <slot> по умолчанию:
class MyContainer extends HTMLElement { connectedCallback() { const root = this.attachShadow({ mode: "open" }); root.innerHTML = ` <style> :host { display: block; width: 100%; height: 100%; } .card { display: flex; flex-direction: column; padding: 0.6rem; height: 100%; } .body { flex: 1; position: relative; } ::slotted(*) { position: absolute; inset: 0; } </style> <div class="card"> <header>{{name}}</header> <div class="body"><slot></slot></div> </div> `; }}Позиция <slot> и есть место, где отрисовывается подпространство — обёртка NestedSpaceFit со стороны рендерера измеряет фактический прямоугольник слота через ResizeObserver и вписывает вложенную сцену внутрь него. Виджеты без <slot> молча не показывают подпространство; это отказ для листовых виджетов.
Для попиксельно точной анимации погружения откройте область тела под part="subspace" (совпадает с поиском measureItemBodyRect рендерера):
<div class="body" part="subspace"><slot></slot></div>Выделение и подсветка
Заголовок раздела «Выделение и подсветка»Состояние проявляется как атрибуты хоста:
:host([data-selected="true"]) .card { box-shadow: 0 0 0 2px var(--accent), 0 0 24px var(--accent-glow);}:host([data-highlighted="true"]) .card { box-shadow: 0 0 0 2px var(--warn);}:host([data-hovered="true"]) .card { border-color: var(--accent);}Точки крепления рёбер
Заголовок раздела «Точки крепления рёбер»Помечайте точки подключения через data-anchor="<side>". Движок раскладки читает getBoundingClientRect():
<div class="anchor" data-anchor="in" style="position:absolute; left:-4px; top:50%"></div><div class="anchor" data-anchor="out" style="position:absolute; right:-4px; top:50%"></div>Встроенные шаблоны
Заголовок раздела «Встроенные шаблоны»Когда вам нужен разовый пользовательский вид без JS-сопроводителя, задайте widget: строковым литералом:
service Cart { aspect { team: "shop"; domain: "shopping" }
widget: " <div class='w-full h-full p-3 rounded-xl bg-arch-card border border-purple-400/50'> <div class='font-semibold text-purple-200'>{{name}}</div> <div class='text-xs text-slate-400'>{{typeName}} · {{aspects.team}}</div> <div class='text-xs text-purple-400/80'>{{aspects.domain}}</div> </div> "}Парсер видит строковое значение и направляет модуль через хост встроенных шаблонов, а не через пользовательский элемент.
Строки в тройных кавычках
Заголовок раздела «Строки в тройных кавычках»Для шаблонов, содержащих двойные кавычки (HTML-атрибуты), используйте тройные кавычки:
service Cart { widget: """ <archui-card variant="outline" accent="#a78bfa"> <archui-header slot="header" name="{{name}}"></archui-header> </archui-card> """}Строки в тройных кавычках — сырые: обратные слеши не являются escape-последовательностями, а интерполяция вида ${expr} намеренно не поддерживается.
Правила подстановки
Заголовок раздела «Правила подстановки»Подстановка в шаблоне строгая и не содержит логики. Нет ни условных операторов, ни циклов, ни форматтеров — только поиск по {{path}}:
| Токен | Разрешается в |
|---|---|
{{name}} | Имя экземпляра |
{{typeName}} (или {{type}} / {{kindName}}) | Имя типа |
{{description}} | Склеенные описания |
{{<dotted.field>}} | Поле по пути |
{{aspects.<dotted>}} | Аспект по пути |
{{{<path>}}} | То же, что выше, но сырое (пропускает экранирование HTML) |
{{widget.<…>}} | Всегда пусто — конфигурация виджета никогда не утекает в содержимое |
Неизвестные пути отрисовываются как пустая строка. Двойные фигурные скобки экранируют; тройные внедряют сырой HTML.
CSS для встроенных шаблонов
Заголовок раздела «CSS для встроенных шаблонов»Утилиты Tailwind, используемые в шаблонах, компилируются на стороне сервера через UnoCSS (совместимый с Tailwind). Сервер обходит каждый строковый шаблон widget:, извлекает имена классов-кандидатов и выставляет скомпилированный CSS по GET /api/widgets/css. Viewer подгружает его при старте и перезапрашивает при обновлениях рабочего пространства.
Пользовательские утилиты arch-* поставляются «из коробки»:
| Класс | Эффект |
|---|---|
bg-arch-card | Предварительно настроенный тёмный градиент для карточек |
bg-arch-card-hot | Вариант с оттенком индиго |
shadow-arch-glow / -lg | Лёгкое акцентное свечение |
shadow-arch-card | Тень и внутренняя подсветка |
text-arch-{50..950} | Расширенная палитра небесно-голубого |
Библиотека примитивов arch.ui
Заголовок раздела «Библиотека примитивов arch.ui»Стандартная библиотека поставляет библиотеку примитивов arch.ui. Пользовательские виджеты и встроенные шаблоны могут компоновать её по имени тега:
service Cart { aspect { team: "shop"; domain: "shopping" }
widget: """ <archui-card variant="outline" accent="#a78bfa"> <archui-header slot="header" icon="server" name="{{name}}"></archui-header> <archui-body slot="body"> <archui-tech-stack></archui-tech-stack> </archui-body> <archui-details slot="details"> <archui-label path="team" label="team"></archui-label> <archui-label path="domain" format="mono"></archui-label> </archui-details> <archui-anchor side="in"></archui-anchor> <archui-anchor side="out"></archui-anchor> </archui-card> """}Токены темы живут под пользовательскими CSS-свойствами --au-*. Переопределяйте их на любом хосте, чтобы перетематизировать поддерево:
:host { --au-accent: #a78bfa; }Токены наслоены через CSS @layer archui.tokens, archui.project;, так что токены проекта выигрывают у значений по умолчанию из стандартной библиотеки независимо от порядка импорта.
И widget, и widget.<…> — обычные поля, поэтому к ним применимы механизмы распространения из главы 18. Используйте cascade *, чтобы связать всю конфигурацию в группу, так что значения по умолчанию для типа можно целиком заменить или хирургически подкрутить:
type module service { cascade * widget: arch-module { icon: service color: info subheader: @@team footer: @@domain }}
// Replace the whole bundle:type service fancy { widget: my-fancy-element // arch-module's icon / color / etc. are NOT inherited — the // cascade-group root was replaced.}
// Or override just a leaf:service Orders { widget.color: green // keeps icon: service, subheader: @@team, etc.}Виджеты рёбер
Заголовок раздела «Виджеты рёбер»Рёбра (линии на доске) тоже могут нести собственный виджет. Механизм зеркалит виджеты модулей — поле widget: на цепочке интерфейса, со свойствами widget.<…>. Интерфейсы обычно объявляют свой виджет на уровне типа:
type interface command { cascade widget: arch-edge cascade widget.color: info}Виджеты рёбер получают el.archEdge = { from, to, id, details } и el.archPath (строку отрисованного SVG-пути). Используйте путь, чтобы рисовать украшения вдоль линии — метки, анимированные точки, акценты-свечения.
Краткий справочник
Заголовок раздела «Краткий справочник»| Хочу … | Сделать это |
|---|---|
| Подкрутить вид виджета по умолчанию | Задать widget.<icon|color|subheader|footer|label1|label2> |
| Полностью заменить виджет | Задать widget: my-element (выбрасывает группу cascade * родителя) |
| Настроить один лист, оставив остальное | widget.color: … |
| Жёстко зафиксировать размер карточки на виджете | Объявить static archWidth / archHeight на элементе, без sizeFor |
| Сделать размер карточки настраиваемым | Реализовать sizeFor(props), читающий props.get("width")/get("height") |
| Отрисовать вложенных детей | Объявить <slot></slot> в shadow DOM, пометить part="subspace" на обёртке |
| Полностью пропустить вложенных детей | Опустить слот — light DOM отбрасывается, листовой виджет |
| Разовый пользовательский вид, без JS | Встроенный шаблон через widget: "<html>...</html>" |
| Компоновать примитивы стандартной библиотеки | Использовать теги archui-* в шаблонах |