@shipload/item-renderer 1.0.0-next.5 → 1.0.0-next.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipload/item-renderer",
3
- "version": "1.0.0-next.5",
3
+ "version": "1.0.0-next.50",
4
4
  "description": "Deterministic SVG rendering for Shipload items",
5
5
  "homepage": "https://github.com/shipload/toolkit/tree/master/packages/item-renderer",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "fonts:copy": "bun run scripts/copy-fonts.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@shipload/sdk": "workspace:^",
48
+ "@shipload/sdk": "^1.0.0-next.50",
49
49
  "@wharfkit/antelope": "1.2.0"
50
50
  },
51
51
  "devDependencies": {
package/src/index.ts CHANGED
@@ -5,8 +5,18 @@ export const VERSION = '0.1.0'
5
5
  export {InvalidPayloadError, UnknownItemError, RenderError} from './errors.ts'
6
6
 
7
7
  // Payload
8
- export {encodePayload, decodePayload} from './payload/codec.ts'
9
- export type {CargoItem, CargoItemLike} from './payload/codec.ts'
8
+ export {
9
+ encodeCargoItem,
10
+ decodeCargoItem,
11
+ encodeNftPayload,
12
+ decodeNftPayload,
13
+ } from './payload/codec.ts'
14
+ export type {
15
+ CargoItem,
16
+ CargoItemLike,
17
+ NftItemPayload,
18
+ NftItemPayloadLike,
19
+ } from './payload/codec.ts'
10
20
 
11
21
  // Rendering
12
22
  export {renderItem, renderFromPayload, type RenderOptions} from './render.ts'
@@ -17,17 +27,81 @@ export {linkToItemPage, linkToItemImage, linkToItemSocial} from './links.ts'
17
27
  export {itemPageMeta, svgDimensions} from './meta.ts'
18
28
  export type {ItemPageMeta, ItemPageMetaOptions} from './meta.ts'
19
29
 
20
- // Tokens (consumed by testmap tailwind.config)
30
+ // Tokens (consumed by webapp tailwind.config)
21
31
  export {tokens} from './tokens/index.ts'
22
32
  export type {Tokens} from './tokens/index.ts'
23
33
  export type {CategoryColorKey, TierColorKey} from './tokens/colors.ts'
24
34
 
25
- // Category icon primitive
26
- export {categoryIconSvg, categoryIconPath} from './primitives/category-icon.ts'
27
- export type {CategoryIconPathOpts, CategoryIconSvgOpts} from './primitives/category-icon.ts'
35
+ // Resource icon primitive
36
+ export {
37
+ resourceIcon,
38
+ resourceIconBody,
39
+ resourceIconCategories,
40
+ resourceIconSvg,
41
+ } from './primitives/resource-icon.ts'
42
+ export type {ResourceIconInlineOpts, ResourceIconSvgOpts} from './primitives/resource-icon.ts'
43
+
44
+ // Component icon primitive
45
+ export {
46
+ componentIcon,
47
+ componentIconBody,
48
+ componentIconSlugs,
49
+ componentIconSlugForName,
50
+ componentIconSvg,
51
+ } from './primitives/component-icon.ts'
52
+ export type {
53
+ ComponentIconInlineOpts,
54
+ ComponentIconSlug,
55
+ ComponentIconSvgOpts,
56
+ } from './primitives/component-icon.ts'
57
+
58
+ // Entity icon primitive
59
+ export {
60
+ entityIcon,
61
+ entityIconBody,
62
+ entityIconSlugs,
63
+ entityIconSlugForName,
64
+ entityIconSvg,
65
+ } from './primitives/entity-icon.ts'
66
+ export type {
67
+ EntityIconInlineOpts,
68
+ EntityIconSlug,
69
+ EntityIconSvgOpts,
70
+ } from './primitives/entity-icon.ts'
71
+
72
+ // Module icon primitive
73
+ export {
74
+ moduleIcon,
75
+ moduleIconBody,
76
+ moduleIconSlugs,
77
+ moduleIconSlugForName,
78
+ moduleIconSlugForType,
79
+ moduleIconSvg,
80
+ } from './primitives/module-icon.ts'
81
+ export type {
82
+ ModuleIconInlineOpts,
83
+ ModuleIconSlug,
84
+ ModuleIconSvgOpts,
85
+ } from './primitives/module-icon.ts'
86
+
87
+ // Station entity icon primitive
88
+ export {
89
+ stationEntityIcon,
90
+ stationEntityIconBody,
91
+ stationEntityIconKinds,
92
+ stationEntityIconKindForName,
93
+ stationEntityIconKindForPackedItemId,
94
+ stationEntityIconLabelForKind,
95
+ stationEntityIconSvg,
96
+ } from './primitives/station-entity-icon.ts'
97
+ export type {
98
+ StationEntityIconInlineOpts,
99
+ StationEntityIconKind,
100
+ StationEntityIconSvgOpts,
101
+ } from './primitives/station-entity-icon.ts'
28
102
 
29
103
  // Item cell templates
30
- export {renderItemCell, itemCellGroup} from './templates/item-cell.ts'
104
+ export {renderItemCell, itemCellGroup, abbreviateQuantity} from './templates/item-cell.ts'
31
105
  export type {ItemCellProps, ItemCellGroupProps} from './templates/item-cell.ts'
32
106
 
33
107
  // Social card template (1200x630 OG image)
@@ -51,4 +125,3 @@ export {
51
125
  type ResolvedModuleSlot,
52
126
  type ResolvedAttributeGroup,
53
127
  } from '@shipload/sdk'
54
- export type {CategoryIconShape} from '@shipload/sdk'
package/src/links.ts CHANGED
@@ -1,24 +1,27 @@
1
1
  import type {CargoItem} from './payload/codec.ts'
2
- import {encodePayload} from './payload/codec.ts'
2
+ import {encodeCargoItem, encodeNftPayload} from './payload/codec.ts'
3
3
 
4
4
  const DEFAULT_WEBSITE_BASE = 'https://shiploadgame.com'
5
5
  const DEFAULT_IMAGE_BASE = 'https://item.shiploadgame.com'
6
6
 
7
7
  export function linkToItemPage(item: CargoItem, baseUrl = DEFAULT_WEBSITE_BASE): string {
8
- const payload = encodePayload(item)
8
+ const payload = encodeCargoItem(item)
9
9
  return `${baseUrl}/guide/item/${payload}`
10
10
  }
11
11
 
12
12
  export function linkToItemImage(
13
13
  item: CargoItem,
14
14
  ext: 'png' | 'svg',
15
- baseUrl = DEFAULT_IMAGE_BASE
15
+ opts?: {location?: {x: number | bigint; y: number | bigint}; baseUrl?: string}
16
16
  ): string {
17
- const payload = encodePayload(item)
18
- return `${baseUrl}/item/${payload}.${ext}`
17
+ const payload = encodeNftPayload({item, location: opts?.location ?? null})
18
+ return `${opts?.baseUrl ?? DEFAULT_IMAGE_BASE}/item/${payload}.${ext}`
19
19
  }
20
20
 
21
- export function linkToItemSocial(item: CargoItem, baseUrl = DEFAULT_IMAGE_BASE): string {
22
- const payload = encodePayload(item)
23
- return `${baseUrl}/social/${payload}.png`
21
+ export function linkToItemSocial(
22
+ item: CargoItem,
23
+ opts?: {location?: {x: number | bigint; y: number | bigint}; baseUrl?: string}
24
+ ): string {
25
+ const payload = encodeNftPayload({item, location: opts?.location ?? null})
26
+ return `${opts?.baseUrl ?? DEFAULT_IMAGE_BASE}/social/${payload}.png`
24
27
  }
package/src/meta.ts CHANGED
@@ -32,7 +32,7 @@ export function itemPageMeta(
32
32
  return {
33
33
  title: `${displayName(resolved)} · Shipload Guide`,
34
34
  description: describeItem(resolved),
35
- ogImage: linkToItemSocial(item, opts?.imageBaseUrl),
35
+ ogImage: linkToItemSocial(item, {baseUrl: opts?.imageBaseUrl}),
36
36
  ogImageWidth: SOCIAL_CARD_WIDTH,
37
37
  ogImageHeight: SOCIAL_CARD_HEIGHT,
38
38
  }
@@ -6,13 +6,16 @@ import {base64UrlToBytes, bytesToBase64Url} from './base64url.ts'
6
6
  export type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
7
7
  export type CargoItemLike = Parameters<typeof ServerContract.Types.cargo_item.from>[0]
8
8
 
9
- export function encodePayload(input: CargoItemLike): string {
9
+ export type NftItemPayload = InstanceType<typeof ServerContract.Types.nft_item_payload>
10
+ export type NftItemPayloadLike = Parameters<typeof ServerContract.Types.nft_item_payload.from>[0]
11
+
12
+ export function encodeCargoItem(input: CargoItemLike): string {
10
13
  const item = ServerContract.Types.cargo_item.from(input)
11
14
  const bytes = Serializer.encode({object: item}).array
12
15
  return bytesToBase64Url(bytes)
13
16
  }
14
17
 
15
- export function decodePayload(input: string): CargoItem {
18
+ export function decodeCargoItem(input: string): CargoItem {
16
19
  if (input.length === 0) throw new InvalidPayloadError('empty payload')
17
20
  const bytes = base64UrlToBytes(input)
18
21
  try {
@@ -24,3 +27,22 @@ export function decodePayload(input: string): CargoItem {
24
27
  throw new InvalidPayloadError(`cargo_item decode failed: ${(e as Error).message}`)
25
28
  }
26
29
  }
30
+
31
+ export function encodeNftPayload(input: NftItemPayloadLike): string {
32
+ const payload = ServerContract.Types.nft_item_payload.from(input)
33
+ const bytes = Serializer.encode({object: payload}).array
34
+ return bytesToBase64Url(bytes)
35
+ }
36
+
37
+ export function decodeNftPayload(input: string): NftItemPayload {
38
+ if (input.length === 0) throw new InvalidPayloadError('empty payload')
39
+ const bytes = base64UrlToBytes(input)
40
+ try {
41
+ return Serializer.decode({
42
+ data: bytes,
43
+ type: ServerContract.Types.nft_item_payload,
44
+ }) as NftItemPayload
45
+ } catch (e) {
46
+ throw new InvalidPayloadError(`nft_item_payload decode failed: ${(e as Error).message}`)
47
+ }
48
+ }
@@ -0,0 +1,229 @@
1
+ import {el, escapeXml} from './svg.ts'
2
+
3
+ export const componentIconSlugs = [
4
+ 'plate',
5
+ 'frame',
6
+ 'plasma-cell',
7
+ 'resonator',
8
+ 'beam',
9
+ 'sensor',
10
+ 'polymer',
11
+ 'ceramic',
12
+ 'reactor',
13
+ 'resin',
14
+ ] as const
15
+
16
+ export type ComponentIconSlug = (typeof componentIconSlugs)[number]
17
+
18
+ export interface ComponentIconSvgOpts {
19
+ size?: number
20
+ title?: string
21
+ className?: string
22
+ }
23
+
24
+ export interface ComponentIconInlineOpts {
25
+ x: number
26
+ y: number
27
+ size: number
28
+ }
29
+
30
+ const componentIconLabels: Record<ComponentIconSlug, string> = {
31
+ plate: 'Plate',
32
+ frame: 'Frame',
33
+ 'plasma-cell': 'Plasma Cell',
34
+ resonator: 'Resonator',
35
+ beam: 'Beam',
36
+ sensor: 'Sensor',
37
+ polymer: 'Polymer',
38
+ ceramic: 'Ceramic',
39
+ reactor: 'Reactor',
40
+ resin: 'Resin',
41
+ }
42
+
43
+ const componentIconNames: Record<string, ComponentIconSlug> = {
44
+ plate: 'plate',
45
+ frame: 'frame',
46
+ 'plasma cell': 'plasma-cell',
47
+ 'plasma-cell': 'plasma-cell',
48
+ resonator: 'resonator',
49
+ beam: 'beam',
50
+ sensor: 'sensor',
51
+ polymer: 'polymer',
52
+ ceramic: 'ceramic',
53
+ reactor: 'reactor',
54
+ resin: 'resin',
55
+ }
56
+
57
+ function normalizeComponentName(name: string): string {
58
+ return name
59
+ .trim()
60
+ .toLowerCase()
61
+ .replace(/\s*\(?t\d+\)?$/, '')
62
+ }
63
+
64
+ export function componentIconSlugForName(name: string): ComponentIconSlug | null {
65
+ return componentIconNames[normalizeComponentName(name)] ?? null
66
+ }
67
+
68
+ function plateIcon(): string {
69
+ return [
70
+ '<path d="M13 15 H42 L53 25 V47 L43 56 H17 L8 46 V24 Z" fill="#c26d3f" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
71
+ '<path d="M18 20 H41 L48 27 V43 L40 50 H19 L14 44 V26 Z" fill="#7f95a9" stroke="#06142f" stroke-width="2.5" stroke-linejoin="round"/>',
72
+ '<path d="M18 20 H41 L48 27 L31 31 L14 26 Z" fill="#b9c9d6" opacity=".9"/>',
73
+ '<path d="M31 31 L48 27 V43 L40 50 H25 Z" fill="#43586b" opacity=".65"/>',
74
+ '<circle cx="20" cy="25" r="2.6" fill="#c26d3f" stroke="#06142f" stroke-width="1.5"/>',
75
+ '<circle cx="40" cy="25" r="2.6" fill="#c26d3f" stroke="#06142f" stroke-width="1.5"/>',
76
+ '<circle cx="24" cy="45" r="2.6" fill="#c26d3f" stroke="#06142f" stroke-width="1.5"/>',
77
+ '<path d="M24 22 L35 22 M18 38 L25 45" stroke="#f6fbff" stroke-width="2.4" stroke-linecap="round" opacity=".75"/>',
78
+ ].join('')
79
+ }
80
+
81
+ function frameIcon(): string {
82
+ return [
83
+ '<path d="M16 24 H40 V48 H16 Z M26 14 H50 V38 H26 Z M16 24 L26 14 M40 24 L50 14 M40 48 L50 38 M16 48 L26 38" fill="none" stroke="#06142f" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>',
84
+ '<path d="M16 24 H40 V48 H16 Z M26 14 H50 V38 H26 Z M16 24 L26 14 M40 24 L50 14 M40 48 L50 38 M16 48 L26 38" fill="none" stroke="#c4a57b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>',
85
+ '<path d="M19 27 H37 M29 17 H47 M19 45 H37" fill="none" stroke="#edd3a5" stroke-width="2.2" stroke-linecap="round" opacity=".86"/>',
86
+ '<circle cx="16" cy="24" r="3.1" fill="#73ad49" stroke="#06142f" stroke-width="1.5"/>',
87
+ '<circle cx="50" cy="38" r="3.1" fill="#73ad49" stroke="#06142f" stroke-width="1.5"/>',
88
+ ].join('')
89
+ }
90
+
91
+ function plasmaCellIcon(): string {
92
+ return [
93
+ '<path d="M13 19 H51 L56 27 V46 L47 55 H17 L8 46 V27 Z" fill="#7f95a9" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
94
+ '<path d="M18 24 H29 V47 H18 Z M35 24 H46 V47 H35 Z" fill="#b877ff" stroke="#06142f" stroke-width="2.8" stroke-linejoin="round"/>',
95
+ '<path d="M21 37 C23 31 28 32 28 37 M38 34 C40 28 45 30 45 35" fill="none" stroke="#f0d7ff" stroke-width="3.4" stroke-linecap="round"/>',
96
+ '<path d="M19 21 H45 M16 51 H45" stroke="#b9c9d6" stroke-width="2.4" stroke-linecap="round" opacity=".8"/>',
97
+ '<circle cx="49" cy="28" r="2.5" fill="#f0d7ff" stroke="#06142f" stroke-width="1.3"/>',
98
+ ].join('')
99
+ }
100
+
101
+ function resonatorIcon(): string {
102
+ return [
103
+ '<path d="M6 32 H58" stroke="#06142f" stroke-width="10" stroke-linecap="round"/>',
104
+ '<path d="M6 32 H58" stroke="#c26d3f" stroke-width="6" stroke-linecap="round"/>',
105
+ '<circle cx="32" cy="32" r="23" fill="#c26d3f" stroke="#06142f" stroke-width="4"/>',
106
+ '<circle cx="32" cy="32" r="16" fill="#071026" stroke="#06142f" stroke-width="2.5"/>',
107
+ '<path d="M10 32 H19 M45 32 H54" stroke="#4adbff" stroke-width="3.2" stroke-linecap="round"/>',
108
+ '<path d="M32 8 L43 27 L36 55 H28 L21 27 Z" fill="#4adbff" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
109
+ '<path d="M32 8 L32 55 L21 27 Z" fill="#1689c7"/>',
110
+ '<path d="M32 8 L43 27 L32 55 Z" fill="#86efff"/>',
111
+ '<path d="M27 22 L32 14 M37 22 L41 27" stroke="#f7fbff" stroke-width="2.5" stroke-linecap="round"/>',
112
+ '<path d="M12 24 C17 16 24 11 32 10 M52 40 C47 48 40 53 32 54" fill="none" stroke="#4adbff" stroke-width="2.7" stroke-linecap="round"/>',
113
+ ].join('')
114
+ }
115
+
116
+ function beamIcon(): string {
117
+ return [
118
+ '<path d="M5 14 H59 V29 H45 V35 H59 V50 H5 V35 H19 V29 H5 Z" fill="#7f95a9" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
119
+ '<path d="M10 18 H54 V25 H10 Z" fill="#b9c9d6"/>',
120
+ '<path d="M10 39 H54 V46 H10 Z" fill="#43586b"/>',
121
+ '<path d="M19 25 H45 V39 H19 Z" fill="#c26d3f" stroke="#06142f" stroke-width="2.2" stroke-linejoin="round"/>',
122
+ '<path d="M25 29 H39 M25 35 H39" stroke="#b877ff" stroke-width="3.4" stroke-linecap="round"/>',
123
+ '<circle cx="14" cy="22" r="2.2" fill="#c26d3f" stroke="#06142f" stroke-width="1.2"/>',
124
+ '<circle cx="50" cy="42" r="2.2" fill="#c26d3f" stroke="#06142f" stroke-width="1.2"/>',
125
+ ].join('')
126
+ }
127
+
128
+ function sensorIcon(): string {
129
+ return [
130
+ '<path d="M13 18 H51 L56 26 V46 L47 55 H17 L8 46 V26 Z" fill="#7f95a9" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
131
+ '<path d="M18 24 H46 V44 H18 Z" fill="#071026" stroke="#06142f" stroke-width="2.6" stroke-linejoin="round"/>',
132
+ '<path d="M23 39 C25 31 34 27 42 32" fill="none" stroke="#4adbff" stroke-width="4" stroke-linecap="round"/>',
133
+ '<path d="M25 39 C29 36 35 35 40 37" fill="none" stroke="#b7fbff" stroke-width="2.6" stroke-linecap="round"/>',
134
+ '<circle cx="24" cy="39" r="3.5" fill="#4adbff" stroke="#06142f" stroke-width="1.5"/>',
135
+ '<path d="M45 20 L53 12" stroke="#06142f" stroke-width="6" stroke-linecap="round"/>',
136
+ '<path d="M45 20 L53 12" stroke="#4adbff" stroke-width="3" stroke-linecap="round"/>',
137
+ '<path d="M17 50 H31" stroke="#b9c9d6" stroke-width="2.5" stroke-linecap="round"/>',
138
+ ].join('')
139
+ }
140
+
141
+ function polymerIcon(): string {
142
+ return [
143
+ '<ellipse cx="32" cy="15" rx="14" ry="6" fill="#7f95a9" stroke="#06142f" stroke-width="3.5"/>',
144
+ '<path d="M20 16 H44 V49 C39 55 25 55 20 49 Z" fill="#5a8b3e" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
145
+ '<path d="M22 20 C29 16 36 16 42 20 M20 28 C28 34 37 34 44 28 M21 38 C29 33 36 33 43 38 M22 47 C29 51 36 51 42 47" fill="none" stroke="#a8db6f" stroke-width="3.2" stroke-linecap="round"/>',
146
+ '<ellipse cx="32" cy="50" rx="14" ry="6" fill="#7f95a9" stroke="#06142f" stroke-width="3.5"/>',
147
+ '<ellipse cx="32" cy="15" rx="5" ry="2.4" fill="#071026" stroke="#06142f" stroke-width="1.4"/>',
148
+ '<ellipse cx="32" cy="50" rx="5" ry="2.4" fill="#071026" stroke="#06142f" stroke-width="1.4"/>',
149
+ '<path d="M23 15 H41 M23 50 H41" stroke="#b9c9d6" stroke-width="2.3" stroke-linecap="round"/>',
150
+ ].join('')
151
+ }
152
+
153
+ function ceramicIcon(): string {
154
+ return [
155
+ '<path d="M20 10 H44 L55 21 V45 L44 56 H20 L9 45 V21 Z" fill="#c4a57b" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
156
+ '<path d="M23 18 H41 L48 25 V42 L41 49 H23 L16 42 V25 Z" fill="#edd3a5" stroke="#06142f" stroke-width="2.2" stroke-linejoin="round"/>',
157
+ '<path d="M23 18 H41 L48 25 L32 30 L16 25 Z" fill="#f5dfb8"/>',
158
+ '<path d="M32 30 L48 25 V42 L41 49 H32 Z" fill="#9e7c55" opacity=".45"/>',
159
+ '<path d="M24 24 H40 M20 39 H28 M37 45 H43" stroke="#82664b" stroke-width="2.4" stroke-linecap="round" opacity=".75"/>',
160
+ '<circle cx="32" cy="33" r="3.2" fill="#c4a57b" stroke="#06142f" stroke-width="1.4"/>',
161
+ ].join('')
162
+ }
163
+
164
+ function reactorIcon(): string {
165
+ return [
166
+ '<path d="M16 14 H48 L56 25 V46 L47 56 H17 L8 46 V25 Z" fill="#c4a57b" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
167
+ '<path d="M18 18 H46 L51 27 V43 L43 51 H21 L13 43 V27 Z" fill="#82664b" stroke="#06142f" stroke-width="2.5" stroke-linejoin="round"/>',
168
+ '<path d="M23 25 H41 V43 L36 49 H28 L23 43 Z" fill="#b877ff" stroke="#06142f" stroke-width="3" stroke-linejoin="round"/>',
169
+ '<path d="M27 30 V41 M32 28 V45 M37 30 V41" stroke="#f0d7ff" stroke-width="3.4" stroke-linecap="round"/>',
170
+ '<path d="M13 31 H6 V43 H13 M51 31 H58 V43 H51" fill="#c4a57b" stroke="#06142f" stroke-width="3.5" stroke-linejoin="round"/>',
171
+ '<path d="M22 20 H42 M24 53 H40" stroke="#edd3a5" stroke-width="3" stroke-linecap="round"/>',
172
+ ].join('')
173
+ }
174
+
175
+ function resinIcon(): string {
176
+ return [
177
+ '<path d="M24 8 H40 V18 L48 30 V51 L40 58 H24 L16 51 V30 L24 18 Z" fill="#7f95a9" stroke="#06142f" stroke-width="4" stroke-linejoin="round"/>',
178
+ '<path d="M21 33 H43 V50 L37 55 H27 L21 50 Z" fill="#5a8b3e" stroke="#06142f" stroke-width="2.5" stroke-linejoin="round"/>',
179
+ '<path d="M24 10 H40 V18 H24 Z" fill="#4adbff" stroke="#06142f" stroke-width="2.5"/>',
180
+ '<path d="M25 38 C31 43 38 42 43 36" stroke="#a8db6f" stroke-width="4" stroke-linecap="round" fill="none"/>',
181
+ '<circle cx="36" cy="44" r="3" fill="#b7fbff" stroke="#06142f" stroke-width="1.5"/>',
182
+ '<path d="M23 27 H41" stroke="#b9c9d6" stroke-width="2.7" stroke-linecap="round"/>',
183
+ ].join('')
184
+ }
185
+
186
+ const iconBodies: Record<ComponentIconSlug, string> = {
187
+ plate: plateIcon(),
188
+ frame: frameIcon(),
189
+ 'plasma-cell': plasmaCellIcon(),
190
+ resonator: resonatorIcon(),
191
+ beam: beamIcon(),
192
+ sensor: sensorIcon(),
193
+ polymer: polymerIcon(),
194
+ ceramic: ceramicIcon(),
195
+ reactor: reactorIcon(),
196
+ resin: resinIcon(),
197
+ }
198
+
199
+ export function componentIconBody(slug: ComponentIconSlug): string {
200
+ return iconBodies[slug]
201
+ }
202
+
203
+ export function componentIcon(slug: ComponentIconSlug, opts: ComponentIconInlineOpts): string {
204
+ const scale = opts.size / 64
205
+ return el(
206
+ 'g',
207
+ {transform: `translate(${opts.x} ${opts.y}) scale(${scale})`, 'data-component': slug},
208
+ componentIconBody(slug)
209
+ )
210
+ }
211
+
212
+ export function componentIconSvg(slug: ComponentIconSlug, opts: ComponentIconSvgOpts = {}): string {
213
+ const size = opts.size ?? 64
214
+ const title = opts.title ?? `${componentIconLabels[slug]} component icon`
215
+ const children = `<title>${escapeXml(title)}</title>${componentIconBody(slug)}`
216
+ return el(
217
+ 'svg',
218
+ {
219
+ xmlns: 'http://www.w3.org/2000/svg',
220
+ width: size,
221
+ height: size,
222
+ viewBox: '0 0 64 64',
223
+ role: 'img',
224
+ class: opts.className,
225
+ 'aria-label': title,
226
+ },
227
+ children
228
+ )
229
+ }