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

Иллюстрации

Сервер

Изометрическая иллюстрация серверной стойки с настраиваемым количеством блоков и состоянием индикаторов.


Сервер

ServerIllustration — компактная изометрическая иллюстрация серверной стойки. Компонент можно использовать как самостоятельный SVG или встроить в существующую SVG-схему.

Интерактивное демо

Пропсы

Блоки
3
Индикаторы
<!--
  ServerIllustration: стойка серверов, нарисованная изометрической линией.

  Props
    x, y        где на схеме стоит центр основания (в единицах viewBox родительского <svg>)
    units       сколько блоков в стойке (1–6)                                 по умолчанию 3
    lit         подсветить индикаторы акцентом (пришёл запрос)
    standalone  нарисовать собственный <svg> (для использования отдельно от схемы)
    width       CSS-ширина standalone SVG                                      по умолчанию min(88vw, 420px)
    label       подпись для скринридера (только при standalone)

  Цвета берутся из токенов проекта:
    --color-foreground        линии
    --color-background        верхняя грань
    --color-foreground-muted  выключенные индикаторы
    --color-accent            включённые индикаторы (можно переопределить через --mt-accent)

  Размер задан в единицах схемы (масштаб 1), поэтому сервер не растягивается вместе с контейнером.
  Геометрия лежит в module-скрипте ниже: этот файл самодостаточный.
-->
<script module lang="ts">
	export type PathItem = { k: 'p'; d: string; c: string; o?: number };

	const K = Math.sqrt(3) / 2;
	/** Половина стороны основания, высота блока и шаг между блоками. */
	const H = 30;
	const UH = 14;
	const UP = 16;
	export const SERVER = { H, UH, UP, MAX_UNITS: 6 } as const;

	type V3 = [number, number, number];
	const f = (v: number) => Math.round(v * 10) / 10;
	const P = (x: number, y: number, z: number): [number, number] => [(x - y) * K, (x + y) / 2 - z];
	const pts = (list: V3[]) =>
		list
			.map((q) => P(q[0], q[1], q[2]))
			.map((p) => `${String(f(p[0]))} ${String(f(p[1]))}`)
			.join('L');
	const quad = (...q: V3[]) => `M${pts(q)}Z`;
	const seg = (a: V3, b: V3) => `M${pts([a, b])}`;
	const dot = (q: V3) => {
		const p = P(q[0], q[1], q[2]);
		return `M${String(f(p[0]))} ${String(f(p[1]))}h.01`;
	};

	const clampUnits = (n: number) => Math.max(1, Math.min(SERVER.MAX_UNITS, Math.round(n) || 1));

	/** Все контуры стойки: тень, затем блоки снизу вверх. Начало координат — центр основания. */
	export function serverItems(units = 3): PathItem[] {
		const n = clampUnits(units);
		const out: PathItem[] = [
			{ k: 'p', c: 'sv-shadow', d: quad([-H - 5, -H - 5, 0], [H + 5, -H - 5, 0], [H + 5, H + 5, 0], [-H - 5, H + 5, 0]) }
		];
		for (let k = 0; k < n; k++) {
			const z0 = k * UP;
			const z1 = z0 + UH;
			const y = H;
			const c = 2.5;
			const bays = [-24, -14, -4, 6];
			const b = `b${String(k % 3)}`;
			out.push(
				{ k: 'p', c: 'sv-ln sv-fl', d: quad([-H, y, z0], [H, y, z0], [H, y, z1], [-H, y, z1]) },
				{ k: 'p', c: 'sv-ln sv-fr', d: quad([H, -H, z0], [H, H, z0], [H, H, z1], [H, -H, z1]) },
				{ k: 'p', c: 'sv-ln sv-ft', d: quad([-H, -H, z1], [H, -H, z1], [H, H, z1], [-H, H, z1]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: quad([-H + c, -H + c, z1], [H - c, -H + c, z1], [H - c, H - c, z1], [-H + c, H - c, z1]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: quad([bays[0], y, z0 + 3], [bays[3], y, z0 + 3], [bays[3], y, z0 + 11], [bays[0], y, z0 + 11]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: [bays[1], bays[2]].map((x) => seg([x, y, z0 + 3], [x, y, z0 + 11])).join('') },
				{ k: 'p', c: 'sv-ln sv-lo', d: [0, 1, 2].map((i) => seg([bays[i] + 2, y, z0 + 7], [bays[i + 1] - 2, y, z0 + 7])).join('') },
				{ k: 'p', c: 'sv-ln sv-lo', d: [-19, -12.5, -6, 0.5, 7, 13.5, 20].map((v) => seg([H, v, z0 + 4], [H, v, z1 - 4])).join('') },
				{ k: 'p', c: 'sv-led', d: dot([17, y, z0 + 7]) },
				{ k: 'p', c: `sv-led sv-blink ${b}`, d: dot([23, y, z0 + 7]) }
			);
		}
		return out;
	}

	/** [x, y, width, height] для viewBox, когда сервер нарисован отдельно (центр основания в 0,0). */
	export function serverBounds(units = 3): [number, number, number, number] {
		const n = clampUnits(units);
		const top = (n - 1) * UP + UH;
		const xs: number[] = [];
		const ys: number[] = [];
		for (const x of [-H - 5, H + 5])
			for (const y of [-H - 5, H + 5])
				for (const z of [0, top]) {
					const p = P(x, y, z);
					xs.push(p[0]);
					ys.push(p[1]);
				}
		const pad = 6;
		const x0 = Math.min(...xs) - pad;
		const y0 = Math.min(...ys) - pad;
		return [f(x0), f(y0), f(Math.max(...xs) + pad - x0), f(Math.max(...ys) + pad - y0)];
	}
</script>

<script lang="ts">
	type Props = {
		x?: number;
		y?: number;
		units?: number;
		lit?: boolean;
		standalone?: boolean;
		width?: string;
		label?: string;
		class?: string;
	};

	let {
		x = 0,
		y = 0,
		units = 3,
		lit = false,
		standalone = false,
		width = 'min(88vw, 420px)',
		label = 'Сервер',
		class: className = ''
	}: Props = $props();

	const items = $derived(serverItems(units));
	const bounds = $derived(serverBounds(units));
</script>

{#snippet body()}
	<g class="srv" class:lit transform="translate({x} {y})">
		{#each items as it, i (i)}
			<path class={it.c} d={it.d} opacity={it.o} />
		{/each}
	</g>
{/snippet}

{#if standalone}
	<svg
		class="srv-root {className}"
		style:width={width}
		viewBox={bounds.join(' ')}
		role="img"
		aria-label={label}
	>
		{@render body()}
	</svg>
{:else}
	{@render body()}
{/if}

<style>
	.srv-root {
		display: block;
		height: auto;
		overflow: visible;
	}

	.srv {
		--sv-ink: var(--color-foreground);
		--sv-surface: var(--color-background);
		--sv-off: var(--color-foreground-muted);
		--sv-on: var(--mt-accent, var(--color-accent));
		--sv-sw: 2;
	}

	.srv :global(.sv-shadow) {
		fill: var(--sv-ink);
		opacity: 0.07;
	}
	.srv :global(.sv-ln) {
		fill: var(--sv-surface);
		stroke: var(--sv-ink);
		stroke-width: var(--sv-sw);
		stroke-linejoin: round;
		stroke-linecap: round;
	}
	.srv :global(.sv-fl) {
		fill: color-mix(in srgb, var(--sv-ink) 4%, var(--sv-surface));
	}
	.srv :global(.sv-fr) {
		fill: color-mix(in srgb, var(--sv-ink) 9%, var(--sv-surface));
	}
	.srv :global(.sv-ft) {
		fill: var(--sv-surface);
	}
	.srv :global(.sv-lo) {
		fill: none;
		stroke-width: calc(var(--sv-sw) * 0.6);
		opacity: 0.65;
	}

	.srv :global(.sv-led) {
		fill: none;
		stroke: var(--sv-off);
		stroke-width: 4.5;
		stroke-linecap: round;
		transition: stroke 150ms ease-out;
	}
	.srv.lit :global(.sv-led) {
		stroke: var(--sv-on);
	}
	.srv :global(.sv-blink) {
		animation: sv-blink 1.4s ease-in-out infinite;
	}
	.srv :global(.b1) {
		animation-delay: 0.33s;
	}
	.srv :global(.b2) {
		animation-delay: 0.66s;
	}
	@keyframes sv-blink {
		50% {
			opacity: 0.3;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.srv :global(.sv-blink) {
			animation: none;
		}
		.srv :global(.sv-led) {
			transition: none;
		}
	}
</style>
<!--
  ServerIllustration: стойка серверов, нарисованная изометрической линией.

  Props
    x, y        где на схеме стоит центр основания (в единицах viewBox родительского <svg>)
    units       сколько блоков в стойке (1–6)                                 по умолчанию 3
    lit         подсветить индикаторы акцентом (пришёл запрос)
    standalone  нарисовать собственный <svg> (для использования отдельно от схемы)
    width       CSS-ширина standalone SVG                                      по умолчанию min(88vw, 420px)
    label       подпись для скринридера (только при standalone)

  Цвета берутся из токенов проекта:
    --color-foreground        линии
    --color-background        верхняя грань
    --color-foreground-muted  выключенные индикаторы
    --color-accent            включённые индикаторы (можно переопределить через --mt-accent)

  Размер задан в единицах схемы (масштаб 1), поэтому сервер не растягивается вместе с контейнером.
  Геометрия лежит в module-скрипте ниже: этот файл самодостаточный.
-->
<script module lang="ts">
	export type PathItem = { k: 'p'; d: string; c: string; o?: number };

	const K = Math.sqrt(3) / 2;
	/** Половина стороны основания, высота блока и шаг между блоками. */
	const H = 30;
	const UH = 14;
	const UP = 16;
	export const SERVER = { H, UH, UP, MAX_UNITS: 6 } as const;

	type V3 = [number, number, number];
	const f = (v: number) => Math.round(v * 10) / 10;
	const P = (x: number, y: number, z: number): [number, number] => [(x - y) * K, (x + y) / 2 - z];
	const pts = (list: V3[]) =>
		list
			.map((q) => P(q[0], q[1], q[2]))
			.map((p) => `${String(f(p[0]))} ${String(f(p[1]))}`)
			.join('L');
	const quad = (...q: V3[]) => `M${pts(q)}Z`;
	const seg = (a: V3, b: V3) => `M${pts([a, b])}`;
	const dot = (q: V3) => {
		const p = P(q[0], q[1], q[2]);
		return `M${String(f(p[0]))} ${String(f(p[1]))}h.01`;
	};

	const clampUnits = (n: number) => Math.max(1, Math.min(SERVER.MAX_UNITS, Math.round(n) || 1));

	/** Все контуры стойки: тень, затем блоки снизу вверх. Начало координат — центр основания. */
	export function serverItems(units = 3): PathItem[] {
		const n = clampUnits(units);
		const out: PathItem[] = [
			{ k: 'p', c: 'sv-shadow', d: quad([-H - 5, -H - 5, 0], [H + 5, -H - 5, 0], [H + 5, H + 5, 0], [-H - 5, H + 5, 0]) }
		];
		for (let k = 0; k < n; k++) {
			const z0 = k * UP;
			const z1 = z0 + UH;
			const y = H;
			const c = 2.5;
			const bays = [-24, -14, -4, 6];
			const b = `b${String(k % 3)}`;
			out.push(
				{ k: 'p', c: 'sv-ln sv-fl', d: quad([-H, y, z0], [H, y, z0], [H, y, z1], [-H, y, z1]) },
				{ k: 'p', c: 'sv-ln sv-fr', d: quad([H, -H, z0], [H, H, z0], [H, H, z1], [H, -H, z1]) },
				{ k: 'p', c: 'sv-ln sv-ft', d: quad([-H, -H, z1], [H, -H, z1], [H, H, z1], [-H, H, z1]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: quad([-H + c, -H + c, z1], [H - c, -H + c, z1], [H - c, H - c, z1], [-H + c, H - c, z1]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: quad([bays[0], y, z0 + 3], [bays[3], y, z0 + 3], [bays[3], y, z0 + 11], [bays[0], y, z0 + 11]) },
				{ k: 'p', c: 'sv-ln sv-lo', d: [bays[1], bays[2]].map((x) => seg([x, y, z0 + 3], [x, y, z0 + 11])).join('') },
				{ k: 'p', c: 'sv-ln sv-lo', d: [0, 1, 2].map((i) => seg([bays[i] + 2, y, z0 + 7], [bays[i + 1] - 2, y, z0 + 7])).join('') },
				{ k: 'p', c: 'sv-ln sv-lo', d: [-19, -12.5, -6, 0.5, 7, 13.5, 20].map((v) => seg([H, v, z0 + 4], [H, v, z1 - 4])).join('') },
				{ k: 'p', c: 'sv-led', d: dot([17, y, z0 + 7]) },
				{ k: 'p', c: `sv-led sv-blink ${b}`, d: dot([23, y, z0 + 7]) }
			);
		}
		return out;
	}

	/** [x, y, width, height] для viewBox, когда сервер нарисован отдельно (центр основания в 0,0). */
	export function serverBounds(units = 3): [number, number, number, number] {
		const n = clampUnits(units);
		const top = (n - 1) * UP + UH;
		const xs: number[] = [];
		const ys: number[] = [];
		for (const x of [-H - 5, H + 5])
			for (const y of [-H - 5, H + 5])
				for (const z of [0, top]) {
					const p = P(x, y, z);
					xs.push(p[0]);
					ys.push(p[1]);
				}
		const pad = 6;
		const x0 = Math.min(...xs) - pad;
		const y0 = Math.min(...ys) - pad;
		return [f(x0), f(y0), f(Math.max(...xs) + pad - x0), f(Math.max(...ys) + pad - y0)];
	}
</script>

<script lang="ts">
	type Props = {
		x?: number;
		y?: number;
		units?: number;
		lit?: boolean;
		standalone?: boolean;
		width?: string;
		label?: string;
		class?: string;
	};

	let {
		x = 0,
		y = 0,
		units = 3,
		lit = false,
		standalone = false,
		width = 'min(88vw, 420px)',
		label = 'Сервер',
		class: className = ''
	}: Props = $props();

	const items = $derived(serverItems(units));
	const bounds = $derived(serverBounds(units));
</script>

{#snippet body()}
	<g class="srv" class:lit transform="translate({x} {y})">
		{#each items as it, i (i)}
			<path class={it.c} d={it.d} opacity={it.o} />
		{/each}
	</g>
{/snippet}

{#if standalone}
	<svg
		class="srv-root {className}"
		style:width={width}
		viewBox={bounds.join(' ')}
		role="img"
		aria-label={label}
	>
		{@render body()}
	</svg>
{:else}
	{@render body()}
{/if}

<style>
	.srv-root {
		display: block;
		height: auto;
		overflow: visible;
	}

	.srv {
		--sv-ink: var(--color-foreground);
		--sv-surface: var(--color-background);
		--sv-off: var(--color-foreground-muted);
		--sv-on: var(--mt-accent, var(--color-accent));
		--sv-sw: 2;
	}

	.srv :global(.sv-shadow) {
		fill: var(--sv-ink);
		opacity: 0.07;
	}
	.srv :global(.sv-ln) {
		fill: var(--sv-surface);
		stroke: var(--sv-ink);
		stroke-width: var(--sv-sw);
		stroke-linejoin: round;
		stroke-linecap: round;
	}
	.srv :global(.sv-fl) {
		fill: color-mix(in srgb, var(--sv-ink) 4%, var(--sv-surface));
	}
	.srv :global(.sv-fr) {
		fill: color-mix(in srgb, var(--sv-ink) 9%, var(--sv-surface));
	}
	.srv :global(.sv-ft) {
		fill: var(--sv-surface);
	}
	.srv :global(.sv-lo) {
		fill: none;
		stroke-width: calc(var(--sv-sw) * 0.6);
		opacity: 0.65;
	}

	.srv :global(.sv-led) {
		fill: none;
		stroke: var(--sv-off);
		stroke-width: 4.5;
		stroke-linecap: round;
		transition: stroke 150ms ease-out;
	}
	.srv.lit :global(.sv-led) {
		stroke: var(--sv-on);
	}
	.srv :global(.sv-blink) {
		animation: sv-blink 1.4s ease-in-out infinite;
	}
	.srv :global(.b1) {
		animation-delay: 0.33s;
	}
	.srv :global(.b2) {
		animation-delay: 0.66s;
	}
	@keyframes sv-blink {
		50% {
			opacity: 0.3;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.srv :global(.sv-blink) {
			animation: none;
		}
		.srv :global(.sv-led) {
			transition: none;
		}
	}
</style>

Использование

<script lang="ts">
	import ServerIllustration from '$lib/components/ui-registry/ServerIllustration.svelte';
</script>

<ServerIllustration units={4} lit standalone />
<script lang="ts">
	import ServerIllustration from '$lib/components/ui-registry/ServerIllustration.svelte';
</script>

<ServerIllustration units={4} lit standalone />

Размер standalone-версии ограничен по умолчанию min(88vw, 420px), чтобы иллюстрация не растягивалась на всю ширину блока демо. При необходимости размер можно переопределить через width.

<ServerIllustration units={4} lit standalone width="320px" />
<ServerIllustration units={4} lit standalone width="320px" />

Встраивание в SVG-схему

По умолчанию компонент не создаёт собственный <svg> и рендерит <g>, поэтому его можно позиционировать координатами x и y внутри другой SVG-сцены.

<svg viewBox="-80 -80 160 160">
	<ServerIllustration x={0} y={0} units={3} lit />
</svg>
<svg viewBox="-80 -80 160 160">
	<ServerIllustration x={0} y={0} units={3} lit />
</svg>

Особенности

  • Самодостаточная SVG-иллюстрация без внешних изображений и зависимостей
  • От 1 до 6 серверных блоков
  • Настраиваемое состояние индикаторов через lit
  • Два режима: встроенный <g> и самостоятельный <svg> через standalone
  • Автоматический расчёт viewBox для standalone-режима
  • Контролируемая CSS-ширина standalone-режима через width
  • Поддержка prefers-reduced-motion
  • Цвета наследуются из дизайн-токенов проекта с возможностью переопределить акцент через --mt-accent

Пропсы

Пропс Тип По умолчанию Описание
x number 0 X-координата центра основания во внешней SVG-схеме
y number 0 Y-координата центра основания во внешней SVG-схеме
units number 3 Количество серверных блоков, от 1 до 6
lit boolean false Включить подсветку индикаторов
standalone boolean false Обернуть иллюстрацию в собственный <svg>
width string 'min(88vw, 420px)' CSS-ширина standalone SVG
label string 'Сервер' Доступное имя для standalone SVG
class string '' CSS-класс standalone SVG