Перейти к содержимому

20. Виджеты

Виджет определяет, как модуль отрисовывается на диаграмме. Каждый модуль отрисовывается через какой-нибудь виджет — когда ничего не настроено, управление берёт на себя встроенный элемент Viewer arch-module. Эта глава охватывает два сценария:

  1. Настройка виджета по умолчанию — вы остаётесь с arch-module и подкручиваете его внешний вид через поля widget.*. Покрывает 80% случаев.
  2. Сборка собственного виджета — регистрация своего пользовательского элемента, когда виджета по умолчанию недостаточно.

Оба пути используют одну и ту же поверхность поля 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.c4widget: boundaryarch-c4-boundary
myorg.widgetswidget: cardmyorg-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-reveal0→1 по мере входа узла в compact-читаемый зум (фаза структуры и фаза раскрытия)
CSS-переменные --arch-band-full, --arch-band-full-reveal0→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/Height0 (затем решатель использует свои глобальные значения по умолчанию 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.

Утилиты 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. Пользовательские виджеты и встроенные шаблоны могут компоновать её по имени тега:

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-* в шаблонах