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

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.
@@ -0,0 +1,242 @@
1
+ import {CATEGORY_LABELS, categoryColors, type ResourceCategory} from '@shipload/sdk'
2
+ import {el, escapeXml} from './svg.ts'
3
+
4
+ export const resourceIconCategories = [
5
+ 'ore',
6
+ 'crystal',
7
+ 'gas',
8
+ 'regolith',
9
+ 'biomass',
10
+ ] as const satisfies readonly ResourceCategory[]
11
+
12
+ export interface ResourceIconSvgOpts {
13
+ size?: number
14
+ title?: string
15
+ className?: string
16
+ }
17
+
18
+ export interface ResourceIconInlineOpts {
19
+ x: number
20
+ y: number
21
+ size: number
22
+ }
23
+
24
+ const OUTLINE = '#06142f'
25
+ const HILITE = '#f7fbff'
26
+ const OUTER_STROKE = 4
27
+ const DETAIL_STROKE = 2.5
28
+
29
+ function oreIcon(): string {
30
+ return [
31
+ el('path', {
32
+ d: 'M13 24 L25 11 L43 13 L55 27 L48 49 L27 56 L12 42 Z',
33
+ fill: categoryColors.ore,
34
+ stroke: OUTLINE,
35
+ 'stroke-width': OUTER_STROKE,
36
+ 'stroke-linejoin': 'round',
37
+ }),
38
+ el('path', {d: 'M25 11 L31 30 L13 24 Z', fill: '#e39a5e'}),
39
+ el('path', {d: 'M31 30 L43 13 L55 27 L41 32 Z', fill: '#b85b35'}),
40
+ el('path', {d: 'M31 30 L41 32 L48 49 L27 56 Z', fill: '#8f432e'}),
41
+ el('path', {
42
+ d: 'M31 30 L25 11 M31 30 L13 24 M31 30 L41 32 M41 32 L48 49 M31 30 L27 56',
43
+ fill: 'none',
44
+ stroke: OUTLINE,
45
+ 'stroke-width': 1.5,
46
+ 'stroke-linecap': 'round',
47
+ 'stroke-linejoin': 'round',
48
+ opacity: 0.28,
49
+ }),
50
+ el('path', {
51
+ d: 'M21 23 L27 18 M42 20 L48 27',
52
+ fill: 'none',
53
+ stroke: '#ffd29c',
54
+ 'stroke-width': DETAIL_STROKE,
55
+ 'stroke-linecap': 'round',
56
+ opacity: 0.72,
57
+ }),
58
+ ].join('')
59
+ }
60
+
61
+ function crystalIcon(): string {
62
+ return [
63
+ el('path', {
64
+ d: 'M32 6 L49 24 L39 58 L23 58 L14 25 Z',
65
+ fill: categoryColors.crystal,
66
+ stroke: OUTLINE,
67
+ 'stroke-width': OUTER_STROKE,
68
+ 'stroke-linejoin': 'round',
69
+ }),
70
+ el('path', {d: 'M32 6 L32 58 L14 25 Z', fill: '#1fb9e4'}),
71
+ el('path', {d: 'M32 6 L49 24 L32 58 Z', fill: '#8df0ff'}),
72
+ el('path', {d: 'M23 58 L32 32 L39 58 Z', fill: '#2f87d7', opacity: 0.72}),
73
+ el('path', {
74
+ d: 'M32 6 L32 58 M14 25 L32 32 L49 24 M23 58 L32 32 L39 58',
75
+ fill: 'none',
76
+ stroke: OUTLINE,
77
+ 'stroke-width': 1.5,
78
+ 'stroke-linecap': 'round',
79
+ 'stroke-linejoin': 'round',
80
+ opacity: 0.25,
81
+ }),
82
+ el('path', {
83
+ d: 'M25 20 L31 13 M38 19 L43 25',
84
+ fill: 'none',
85
+ stroke: HILITE,
86
+ 'stroke-width': DETAIL_STROKE,
87
+ 'stroke-linecap': 'round',
88
+ opacity: 0.78,
89
+ }),
90
+ ].join('')
91
+ }
92
+
93
+ function gasIcon(): string {
94
+ return [
95
+ el('circle', {
96
+ cx: 32,
97
+ cy: 32,
98
+ r: 23,
99
+ fill: categoryColors.gas,
100
+ stroke: OUTLINE,
101
+ 'stroke-width': OUTER_STROKE,
102
+ }),
103
+ el('path', {
104
+ d: 'M17 33 C21 20 38 18 44 27 C51 38 37 51 25 43',
105
+ fill: 'none',
106
+ stroke: '#7a48d9',
107
+ 'stroke-width': 7.5,
108
+ 'stroke-linecap': 'round',
109
+ }),
110
+ el('path', {
111
+ d: 'M21 33 C25 25 36 25 39 31 C43 38 35 44 28 39',
112
+ fill: 'none',
113
+ stroke: '#f0d7ff',
114
+ 'stroke-width': 5,
115
+ 'stroke-linecap': 'round',
116
+ }),
117
+ el('circle', {
118
+ cx: 44,
119
+ cy: 18,
120
+ r: 5,
121
+ fill: '#f0d7ff',
122
+ stroke: OUTLINE,
123
+ 'stroke-width': DETAIL_STROKE,
124
+ }),
125
+ el('circle', {
126
+ cx: 19,
127
+ cy: 47,
128
+ r: 4,
129
+ fill: '#f0d7ff',
130
+ stroke: OUTLINE,
131
+ 'stroke-width': DETAIL_STROKE,
132
+ }),
133
+ ].join('')
134
+ }
135
+
136
+ function regolithIcon(): string {
137
+ return [
138
+ el('path', {
139
+ d: 'M16 14 H45 L54 25 V46 L43 55 H17 L9 43 V24 Z',
140
+ fill: categoryColors.regolith,
141
+ stroke: OUTLINE,
142
+ 'stroke-width': OUTER_STROKE,
143
+ 'stroke-linejoin': 'round',
144
+ }),
145
+ el('path', {d: 'M16 14 H45 L54 25 L34 28 Z', fill: '#dec59a'}),
146
+ el('path', {d: 'M9 43 L34 28 L43 55 H17 Z', fill: '#9e7c55', opacity: 0.78}),
147
+ el('path', {
148
+ d: 'M16 14 L34 28 L54 25 M34 28 L43 55 M34 28 L9 43',
149
+ fill: 'none',
150
+ stroke: OUTLINE,
151
+ 'stroke-width': 1.5,
152
+ 'stroke-linecap': 'round',
153
+ 'stroke-linejoin': 'round',
154
+ opacity: 0.25,
155
+ }),
156
+ el('circle', {cx: 25, cy: 28, r: 4, fill: '#806248', stroke: OUTLINE, 'stroke-width': 1.5}),
157
+ el('circle', {cx: 42, cy: 40, r: 5, fill: '#8b6a4d', stroke: OUTLINE, 'stroke-width': 1.5}),
158
+ el('circle', {
159
+ cx: 22,
160
+ cy: 44,
161
+ r: 3,
162
+ fill: '#e7d0a9',
163
+ stroke: OUTLINE,
164
+ 'stroke-width': 1.25,
165
+ }),
166
+ el('path', {
167
+ d: 'M35 18 L45 23',
168
+ fill: 'none',
169
+ stroke: '#f0dbb7',
170
+ 'stroke-width': DETAIL_STROKE,
171
+ 'stroke-linecap': 'round',
172
+ opacity: 0.72,
173
+ }),
174
+ ].join('')
175
+ }
176
+
177
+ function biomassIcon(): string {
178
+ return [
179
+ el('path', {
180
+ d: 'M32 7 C37 14 42 17 50 16 C50 24 54 29 57 35 C51 39 48 44 47 52 C39 50 36 55 31 59 C27 52 21 50 13 53 C14 45 10 40 7 34 C13 29 14 23 12 16 C21 18 26 14 32 7 Z',
181
+ fill: categoryColors.biomass,
182
+ stroke: OUTLINE,
183
+ 'stroke-width': OUTER_STROKE,
184
+ 'stroke-linejoin': 'round',
185
+ }),
186
+ el('circle', {cx: 32, cy: 32, r: 13, fill: '#73ad49', stroke: OUTLINE, 'stroke-width': 2}),
187
+ el('circle', {cx: 27, cy: 28, r: 5, fill: '#a8db6f', stroke: OUTLINE, 'stroke-width': 1.5}),
188
+ el('circle', {cx: 38, cy: 34, r: 5, fill: '#2f6f35', stroke: OUTLINE, 'stroke-width': 1.5}),
189
+ el('circle', {cx: 30, cy: 41, r: 4, fill: '#89c85a', stroke: OUTLINE, 'stroke-width': 1.5}),
190
+ el('circle', {
191
+ cx: 40,
192
+ cy: 24,
193
+ r: 3,
194
+ fill: '#c9ef8a',
195
+ stroke: OUTLINE,
196
+ 'stroke-width': 1.25,
197
+ }),
198
+ ].join('')
199
+ }
200
+
201
+ const iconBodies: Record<ResourceCategory, string> = {
202
+ ore: oreIcon(),
203
+ crystal: crystalIcon(),
204
+ gas: gasIcon(),
205
+ regolith: regolithIcon(),
206
+ biomass: biomassIcon(),
207
+ }
208
+
209
+ export function resourceIconBody(category: ResourceCategory): string {
210
+ return iconBodies[category]
211
+ }
212
+
213
+ export function resourceIcon(category: ResourceCategory, opts: ResourceIconInlineOpts): string {
214
+ const scale = opts.size / 64
215
+ return el(
216
+ 'g',
217
+ {transform: `translate(${opts.x} ${opts.y}) scale(${scale})`, 'data-resource': category},
218
+ resourceIconBody(category)
219
+ )
220
+ }
221
+
222
+ export function resourceIconSvg(
223
+ category: ResourceCategory,
224
+ opts: ResourceIconSvgOpts = {}
225
+ ): string {
226
+ const size = opts.size ?? 64
227
+ const title = opts.title ?? `${CATEGORY_LABELS[category]} resource icon`
228
+ const children = `<title>${escapeXml(title)}</title>${resourceIconBody(category)}`
229
+ return el(
230
+ 'svg',
231
+ {
232
+ xmlns: 'http://www.w3.org/2000/svg',
233
+ width: size,
234
+ height: size,
235
+ viewBox: '0 0 64 64',
236
+ role: 'img',
237
+ class: opts.className,
238
+ 'aria-label': title,
239
+ },
240
+ children
241
+ )
242
+ }
@@ -43,7 +43,7 @@ export function spanParagraph(props: SpanParagraphProps): {svg: string; lineCoun
43
43
  const chars = props.charsPerLine ?? 36
44
44
  const lh = props.lineHeight ?? 14
45
45
  const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
46
- const highlightColor = props.highlightColor ?? tokens.colors.text.accent
46
+ const highlightColor = props.highlightColor ?? tokens.colors.text.primary
47
47
  const size = props.fontSize ?? tokens.typography.sizes.body
48
48
 
49
49
  const plain = props.spans.map((s) => s.text).join('')
@@ -1,6 +1,7 @@
1
1
  import {el} from './svg.ts'
2
2
  import {text} from './text.ts'
3
3
  import {tokens} from '../tokens/index.ts'
4
+ import {statStars, STAR_BLOCK_WIDTH} from './stat-stars.ts'
4
5
 
5
6
  export interface StatBarProps {
6
7
  x: number
@@ -11,6 +12,7 @@ export interface StatBarProps {
11
12
  value: number | null // 0..1023, or null for ranges mode (no value text, no fill)
12
13
  color: string
13
14
  inverted?: boolean
15
+ stars?: number // 0..3 per-stat rating; only drawn in values mode
14
16
  }
15
17
 
16
18
  export function statBar({
@@ -22,6 +24,7 @@ export function statBar({
22
24
  value,
23
25
  color,
24
26
  inverted,
27
+ stars,
25
28
  }: StatBarProps): string {
26
29
  const h = tokens.spacing.statBarHeight
27
30
 
@@ -59,16 +62,29 @@ export function statBar({
59
62
  const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
63
  const filled = Math.floor(width * displayFraction)
61
64
 
65
+ const VALUE_COL_W = 34 // room for up to 4 mono digits at size 14, anchored end
66
+
67
+ // value text = primary; identity color = bar + code + chrome
62
68
  labelOut += text({
63
69
  x: x + width,
64
70
  y: y - 6,
65
71
  value: String(clamped),
66
72
  size: tokens.typography.sizes.statValue,
67
73
  weight: 700,
68
- color,
74
+ family: tokens.typography.mono,
75
+ color: tokens.colors.text.primary,
69
76
  anchor: 'end',
70
77
  })
71
78
 
79
+ // stars sit immediately left of the reserved value column
80
+ if (stars !== undefined) {
81
+ labelOut += statStars({
82
+ x: x + width - VALUE_COL_W - STAR_BLOCK_WIDTH,
83
+ y: y - 6,
84
+ n: stars,
85
+ })
86
+ }
87
+
72
88
  const bar = el('rect', {
73
89
  x,
74
90
  y,
@@ -0,0 +1,36 @@
1
+ import {el} from './svg.ts'
2
+
3
+ // 24×24 path, identical to the webapp ItemStatRow star.
4
+ const STAR_PATH =
5
+ 'M12 2.2l2.95 5.98 6.6.96-4.77 4.65 1.13 6.57L12 17.23 6.09 20.36l1.13-6.57L2.45 9.14l6.6-.96z'
6
+
7
+ export const STAR_SIZE = 9
8
+ export const STAR_GAP = 1
9
+ export const MAX_STARS = 3
10
+ export const STAR_BLOCK_WIDTH = MAX_STARS * STAR_SIZE + (MAX_STARS - 1) * STAR_GAP
11
+
12
+ const ON_COLOR = '#ffce5c'
13
+ const EMPTY_COLOR = 'rgba(255,255,255,0.16)'
14
+ const SCALE = STAR_SIZE / 24
15
+
16
+ export interface StatStarsProps {
17
+ x: number // left edge of the star block
18
+ y: number // vertical center the stars should sit on (text baseline)
19
+ n: number
20
+ }
21
+
22
+ // Draws MAX_STARS glyphs left-to-right starting at x; the first `n` are filled.
23
+ export function statStars({x, y, n}: StatStarsProps): string {
24
+ const filled = Math.max(0, Math.min(MAX_STARS, Math.floor(n)))
25
+ const top = y - STAR_SIZE + 1 // center the glyph on the text baseline
26
+ let out = ''
27
+ for (let i = 0; i < MAX_STARS; i++) {
28
+ const gx = x + i * (STAR_SIZE + STAR_GAP)
29
+ out += el('path', {
30
+ d: STAR_PATH,
31
+ transform: `translate(${gx} ${top}) scale(${SCALE})`,
32
+ fill: i < filled ? ON_COLOR : EMPTY_COLOR,
33
+ })
34
+ }
35
+ return out
36
+ }
@@ -0,0 +1,261 @@
1
+ import {getPackedEntityType} from '@shipload/sdk'
2
+ import {el, escapeXml} from './svg.ts'
3
+
4
+ export const stationEntityIconKinds = [
5
+ 'hub',
6
+ 'warehouse',
7
+ 'extractor',
8
+ 'factory',
9
+ 'mdriver',
10
+ 'mcatcher',
11
+ ] as const
12
+
13
+ export type StationEntityIconKind = (typeof stationEntityIconKinds)[number]
14
+
15
+ export interface StationEntityIconSvgOpts {
16
+ size?: number
17
+ title?: string
18
+ className?: string
19
+ }
20
+
21
+ export interface StationEntityIconInlineOpts {
22
+ x: number
23
+ y: number
24
+ size: number
25
+ }
26
+
27
+ const stationEntityIconLabels: Record<StationEntityIconKind, string> = {
28
+ hub: 'Station Hub',
29
+ warehouse: 'Warehouse',
30
+ extractor: 'Extractor',
31
+ factory: 'Factory',
32
+ mdriver: 'Mass Driver',
33
+ mcatcher: 'Mass Catcher',
34
+ }
35
+
36
+ const stationEntityIconNames: Record<string, StationEntityIconKind> = {
37
+ hub: 'hub',
38
+ 'station hub': 'hub',
39
+ warehouse: 'warehouse',
40
+ extractor: 'extractor',
41
+ 'mining rig': 'extractor',
42
+ factory: 'factory',
43
+ mdriver: 'mdriver',
44
+ 'meteor driver': 'mdriver',
45
+ 'mass driver': 'mdriver',
46
+ mcatcher: 'mcatcher',
47
+ 'meteor catcher': 'mcatcher',
48
+ 'mass catcher': 'mcatcher',
49
+ }
50
+
51
+ const kindSet = new Set<string>(stationEntityIconKinds)
52
+
53
+ function normalizeName(name: string): string {
54
+ return name
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/\s*\(?t\d+\)?$/, '')
58
+ }
59
+
60
+ export function stationEntityIconLabelForKind(kind: StationEntityIconKind): string {
61
+ return stationEntityIconLabels[kind]
62
+ }
63
+
64
+ export function stationEntityIconKindForName(name: string): StationEntityIconKind | null {
65
+ return stationEntityIconNames[normalizeName(name)] ?? null
66
+ }
67
+
68
+ export function stationEntityIconKindForPackedItemId(
69
+ packedItemId: number
70
+ ): StationEntityIconKind | null {
71
+ const kind = getPackedEntityType(packedItemId)?.toString()
72
+ return kind && kindSet.has(kind) ? (kind as StationEntityIconKind) : null
73
+ }
74
+
75
+ const OUTLINE = '#070712'
76
+
77
+ function wrap(children: string): string {
78
+ return el(
79
+ 'g',
80
+ {
81
+ fill: 'none',
82
+ stroke: OUTLINE,
83
+ 'stroke-width': 8,
84
+ 'stroke-linejoin': 'round',
85
+ 'stroke-linecap': 'round',
86
+ },
87
+ children
88
+ )
89
+ }
90
+
91
+ function hubIcon(): string {
92
+ return wrap(
93
+ [
94
+ '<path d="M128 18l36 22h42l10 10v42l22 36-22 36v42l-10 10h-42l-36 22-36-22H50l-10-10v-42l-22-36 22-36V50l10-10h42z" fill="#f7e8a8"/>',
95
+ '<path d="M128 31l31 20h36l8 8v36l20 33-20 33v36l-8 8h-36l-31 20-31-20H61l-8-8v-36l-20-33 20-33V59l8-8h36z" fill="#382f55"/>',
96
+ '<path d="M83 63h90l20 20v90l-20 20H83l-20-20V83z" fill="#4d406d"/>',
97
+ '<path d="M128 51l29 22 35 6-7 36 16 33-33 17-17 33-23-26-23 26-17-33-33-17 16-33-7-36 35-6z" fill="#312949"/>',
98
+ '<circle cx="128" cy="128" r="57" fill="#f8e7a8"/>',
99
+ '<circle cx="128" cy="128" r="43" fill="#dba92e"/>',
100
+ '<circle cx="128" cy="128" r="31" fill="#f3c53a" stroke-width="5"/>',
101
+ '<path d="M128 82l11 32 32-8-24 24 23 25-31-9-11 32-11-32-31 9 23-25-24-24 32 8z" fill="#fff7b8"/>',
102
+ '<path d="M104 42h48v19h-48zM104 195h48v19h-48zM42 104h19v48H42zM195 104h19v48h-19z" fill="#f2d784"/>',
103
+ '<path d="M113 50h30M113 204h30M50 113v30M204 113v30" stroke="#ffca22" stroke-width="7"/>',
104
+ '<path d="M80 80l11 11M176 80l-11 11M80 176l11-11M176 176l-11-11" stroke="#ffca22" stroke-width="7"/>',
105
+ ].join('')
106
+ )
107
+ }
108
+
109
+ function warehouseIcon(): string {
110
+ return wrap(
111
+ [
112
+ '<path d="M48 30h160l18 18v160l-18 18H48l-18-18V48z" fill="#f7e8a8"/>',
113
+ '<path d="M58 40h140l16 16v144l-16 16H58l-16-16V56z" fill="#362d53"/>',
114
+ '<path d="M77 59h42l10 10v54l-10 10H77l-10-10V69z" fill="#f28a15"/>',
115
+ '<path d="M137 59h42l10 10v54l-10 10h-42l-10-10V69z" fill="#f28a15"/>',
116
+ '<path d="M60 140h136l11 11v47l-11 11H60l-11-11v-47z" fill="#f28a15"/>',
117
+ '<path d="M67 151h122M67 165h122M67 179h122M67 193h122" stroke="#9b4d0f" stroke-width="5"/>',
118
+ '<path d="M128 44v95" stroke="#211c35" stroke-width="15"/>',
119
+ '<path d="M113 51h30M113 119h30M50 83v45M206 83v45M81 216h35M140 216h35" stroke="#ffb02d" stroke-width="7"/>',
120
+ '<path d="M91 79v35M108 79v35M151 79v35M168 79v35" stroke="#8c4510" stroke-width="6"/>',
121
+ '<path d="M75 126h106M75 139h106" stroke="#f7e8a8" stroke-width="7"/>',
122
+ '<path d="M87 151l-16 16M179 151l-16 16" stroke="#fff2bc" stroke-width="5"/>',
123
+ ].join('')
124
+ )
125
+ }
126
+
127
+ function extractorIcon(): string {
128
+ return wrap(
129
+ [
130
+ '<path d="M51 29h154l21 21v154l-21 21H51l-21-21V50z" fill="#f7e8a8"/>',
131
+ '<path d="M62 39h132l20 20v136l-20 20H62l-20-20V59z" fill="#332b50"/>',
132
+ '<path d="M72 63h51l10 10v82l-10 10H72l-10-10V73z" fill="#237e75"/>',
133
+ '<path d="M133 63h51l10 10v82l-10 10h-51l-10-10V73z" fill="#2b9389"/>',
134
+ '<path d="M70 92h55M70 122h55M133 92h55M133 122h55" stroke="#185b55" stroke-width="7"/>',
135
+ '<path d="M128 50v119" stroke="#211b35" stroke-width="14"/>',
136
+ '<path d="M74 54h108M65 164h126" stroke="#f7e8a8" stroke-width="7"/>',
137
+ '<path d="M60 36h136M60 220h136" stroke="#4b3f6c" stroke-width="12"/>',
138
+ '<path d="M31 88h37v71H31zM188 88h37v71h-37z" fill="#342d50"/>',
139
+ '<path d="M45 101h14v45H45zM197 101h14v45h-14z" fill="#23a89b"/>',
140
+ '<circle cx="128" cy="183" r="40" fill="#332b50"/>',
141
+ '<circle cx="128" cy="183" r="27" fill="#18a99d"/>',
142
+ '<circle cx="128" cy="183" r="16" fill="#14746d" stroke-width="4"/>',
143
+ '<path d="M101 161l12 12M155 161l-12 12M101 205l12-12M155 205l-12-12" stroke="#f7e8a8" stroke-width="7"/>',
144
+ '<path d="M114 38h28M119 127v31" stroke="#5ce9dc" stroke-width="6"/>',
145
+ ].join('')
146
+ )
147
+ }
148
+
149
+ function factoryIcon(): string {
150
+ return wrap(
151
+ [
152
+ '<circle cx="128" cy="128" r="104" fill="#f7e8a8"/>',
153
+ '<circle cx="128" cy="128" r="93" fill="#332b50"/>',
154
+ '<path d="M77 37h102l25 25v132l-25 25H77l-25-25V62z" fill="#433861"/>',
155
+ '<circle cx="128" cy="128" r="72" fill="#2b2541"/>',
156
+ '<path d="M128 72l13 19 23-2 3 24 21 13-13 21 8 22-24 8-10 22-21-13-21 13-10-22-24-8 8-22-13-21 21-13 3-24 23 2z" fill="#f6c51f"/>',
157
+ '<circle cx="128" cy="128" r="24" fill="#49405f"/>',
158
+ '<path d="M72 80h112l14 16v21H58V96zM58 158h140v21l-14 16H72z" fill="#e72c9a"/>',
159
+ '<path d="M58 91l-21 28v52l21 28M198 91l21 28v52l-21 28" stroke="#332b50" stroke-width="17"/>',
160
+ '<path d="M78 84l-30 65M178 84l30 65" stroke="#e72c9a" stroke-width="18"/>',
161
+ '<circle cx="55" cy="80" r="21" fill="#e72c9a"/>',
162
+ '<circle cx="201" cy="80" r="21" fill="#e72c9a"/>',
163
+ '<circle cx="55" cy="80" r="11" fill="#ffc51d"/>',
164
+ '<circle cx="201" cy="80" r="11" fill="#ffc51d"/>',
165
+ '<path d="M50 126l-9 22M206 126l9 22" stroke="#332b50" stroke-width="7"/>',
166
+ '<path d="M83 93l-15 15M95 93l-15 15M161 170l-15 15M173 170l-15 15" stroke="#ffe5a4" stroke-width="5"/>',
167
+ ].join('')
168
+ )
169
+ }
170
+
171
+ function mdriverIcon(): string {
172
+ return wrap(
173
+ [
174
+ '<path d="M48 28h160l20 20v160l-20 20H48l-20-20V48z" fill="#f7e8a8"/>',
175
+ '<path d="M59 39h138l18 18v142l-18 18H59l-18-18V57z" fill="#332b50"/>',
176
+ '<circle cx="128" cy="128" r="54" fill="#2a2342"/>',
177
+ '<circle cx="128" cy="128" r="27" fill="#18cfe5"/>',
178
+ '<path d="M52 63h42M162 193h42M52 193h42M162 63h42" stroke="#f7e8a8" stroke-width="7"/>',
179
+ '<path d="M31 128h194" stroke="#19162c" stroke-width="48"/>',
180
+ '<path d="M40 128h176" stroke="#20d8ec" stroke-width="30"/>',
181
+ '<path d="M56 128h144" stroke="#75eff8" stroke-width="13"/>',
182
+ '<path d="M38 128l42-42M176 170l42-42" stroke="#19162c" stroke-width="34"/>',
183
+ '<path d="M50 116l58-58M148 198l58-58" stroke="#332b50" stroke-width="26"/>',
184
+ '<path d="M84 94l89 89M82 162l89-89" stroke="#f7e8a8" stroke-width="9"/>',
185
+ '<path d="M96 102l64 64M96 154l64-64" stroke="#1f1a31" stroke-width="9"/>',
186
+ '<path d="M97 43h62M97 213h62M43 97v62M213 97v62" stroke="#20d8ec" stroke-width="7"/>',
187
+ '<path d="M70 68l-17 17M186 188l17-17" stroke="#ffffff" stroke-opacity=".65" stroke-width="5"/>',
188
+ ].join('')
189
+ )
190
+ }
191
+
192
+ function mcatcherIcon(): string {
193
+ return wrap(
194
+ [
195
+ '<path d="M38 61h180l-17 129-45 39H100l-45-39z" fill="#f7e8a8"/>',
196
+ '<path d="M49 72h158l-17 108-39 35h-46l-39-35z" fill="#332b50"/>',
197
+ '<path d="M61 73c7 58 36 88 67 88s60-30 67-88z" fill="#36ddeb"/>',
198
+ '<path d="M72 83c8 43 29 63 56 63s48-20 56-63" stroke="#90f6ff" stroke-width="10"/>',
199
+ '<path d="M65 64c24-29 102-29 126 0l-12 22c-26-18-76-18-102 0z" fill="#f7e8a8"/>',
200
+ '<path d="M74 76c21-19 87-19 108 0" stroke="#fff4bd" stroke-width="9"/>',
201
+ '<path d="M43 78l-18 55 24 67 40 29M213 78l18 55-24 67-40 29" stroke="#332b50" stroke-width="19"/>',
202
+ '<path d="M58 82v59M198 82v59" stroke="#8b45de" stroke-width="21"/>',
203
+ '<path d="M75 162l31 53M181 162l-31 53" stroke="#8b45de" stroke-width="25"/>',
204
+ '<path d="M109 174h38l12 23-12 32h-38l-12-32z" fill="#352a55"/>',
205
+ '<path d="M116 183h24v18h-24z" fill="#35ddeb"/>',
206
+ '<path d="M128 160v54" stroke="#35ddeb" stroke-width="8"/>',
207
+ '<path d="M79 169l12 11M177 169l-12 11" stroke="#58edf6" stroke-width="6"/>',
208
+ '<path d="M87 104c22 17 60 17 82 0" stroke="#bcfbff" stroke-opacity=".75" stroke-width="5"/>',
209
+ ].join('')
210
+ )
211
+ }
212
+
213
+ const iconBodies: Record<StationEntityIconKind, string> = {
214
+ hub: hubIcon(),
215
+ warehouse: warehouseIcon(),
216
+ extractor: extractorIcon(),
217
+ factory: factoryIcon(),
218
+ mdriver: mdriverIcon(),
219
+ mcatcher: mcatcherIcon(),
220
+ }
221
+
222
+ export function stationEntityIconBody(kind: StationEntityIconKind): string {
223
+ return iconBodies[kind]
224
+ }
225
+
226
+ export function stationEntityIcon(
227
+ kind: StationEntityIconKind,
228
+ opts: StationEntityIconInlineOpts
229
+ ): string {
230
+ const scale = opts.size / 256
231
+ return el(
232
+ 'g',
233
+ {
234
+ transform: `translate(${opts.x} ${opts.y}) scale(${scale})`,
235
+ 'data-station-entity': kind,
236
+ },
237
+ stationEntityIconBody(kind)
238
+ )
239
+ }
240
+
241
+ export function stationEntityIconSvg(
242
+ kind: StationEntityIconKind,
243
+ opts: StationEntityIconSvgOpts = {}
244
+ ): string {
245
+ const size = opts.size ?? 64
246
+ const title = opts.title ?? `${stationEntityIconLabels[kind]} entity icon`
247
+ const children = `<title>${escapeXml(title)}</title>${stationEntityIconBody(kind)}`
248
+ return el(
249
+ 'svg',
250
+ {
251
+ xmlns: 'http://www.w3.org/2000/svg',
252
+ width: size,
253
+ height: size,
254
+ viewBox: '0 0 256 256',
255
+ role: 'img',
256
+ class: opts.className,
257
+ 'aria-label': title,
258
+ },
259
+ children
260
+ )
261
+ }
@@ -1,6 +1,14 @@
1
1
  import {el} from './svg.ts'
2
2
  import {tokens} from '../tokens/index.ts'
3
3
 
4
+ export interface TextSpan {
5
+ value: string
6
+ size?: number
7
+ weight?: 400 | 600 | 700 | 500
8
+ color?: string
9
+ dx?: number
10
+ }
11
+
4
12
  export interface TextProps {
5
13
  x: number
6
14
  y: number
@@ -12,9 +20,14 @@ export interface TextProps {
12
20
  anchor?: 'start' | 'middle' | 'end'
13
21
  letterSpacing?: number
14
22
  dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
23
+ // Optional trailing tspans that flow inline after value (no manual width math).
24
+ spans?: TextSpan[]
15
25
  }
16
26
 
17
27
  export function text(props: TextProps): string {
28
+ const body = props.spans?.length
29
+ ? escapeValue(props.value) + props.spans.map(renderSpan).join('')
30
+ : escapeValue(props.value)
18
31
  return el(
19
32
  'text',
20
33
  {
@@ -28,7 +41,20 @@ export function text(props: TextProps): string {
28
41
  'letter-spacing': props.letterSpacing,
29
42
  'dominant-baseline': props.dominantBaseline,
30
43
  },
31
- escapeValue(props.value)
44
+ body
45
+ )
46
+ }
47
+
48
+ function renderSpan(span: TextSpan): string {
49
+ return el(
50
+ 'tspan',
51
+ {
52
+ dx: span.dx,
53
+ 'font-size': span.size,
54
+ 'font-weight': span.weight,
55
+ fill: span.color,
56
+ },
57
+ escapeValue(span.value)
32
58
  )
33
59
  }
34
60