@shipload/item-renderer 0.1.0

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.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/.gitignore +6 -0
  3. package/Makefile +50 -0
  4. package/biome.json +18 -0
  5. package/bun.lock +123 -0
  6. package/package.json +51 -0
  7. package/scripts/check-bundle-size.ts +37 -0
  8. package/scripts/copy-fonts.ts +41 -0
  9. package/scripts/preview.ts +43 -0
  10. package/src/errors.ts +22 -0
  11. package/src/fonts/index.ts +36 -0
  12. package/src/fonts/inter-400.woff2 +0 -0
  13. package/src/fonts/inter-600.woff2 +0 -0
  14. package/src/fonts/jetbrains-500.woff2 +0 -0
  15. package/src/fonts/load-bun.ts +16 -0
  16. package/src/fonts/orbitron-700.woff2 +0 -0
  17. package/src/index.ts +46 -0
  18. package/src/links.ts +19 -0
  19. package/src/meta.ts +42 -0
  20. package/src/payload/base64url.ts +27 -0
  21. package/src/payload/codec.ts +26 -0
  22. package/src/primitives/category-icon.ts +87 -0
  23. package/src/primitives/compact-row.ts +38 -0
  24. package/src/primitives/divider.ts +20 -0
  25. package/src/primitives/icon-hex.ts +39 -0
  26. package/src/primitives/module-slot.ts +147 -0
  27. package/src/primitives/panel.ts +24 -0
  28. package/src/primitives/quantity-badge.ts +37 -0
  29. package/src/primitives/span-paragraph.ts +72 -0
  30. package/src/primitives/stat-bar.ts +85 -0
  31. package/src/primitives/svg.ts +25 -0
  32. package/src/primitives/text.ts +42 -0
  33. package/src/primitives/wrap.ts +24 -0
  34. package/src/render.ts +33 -0
  35. package/src/templates/_shared.ts +15 -0
  36. package/src/templates/component.ts +139 -0
  37. package/src/templates/index.ts +30 -0
  38. package/src/templates/item-cell.ts +96 -0
  39. package/src/templates/module.ts +190 -0
  40. package/src/templates/packed-entity.ts +30 -0
  41. package/src/templates/resource.ts +151 -0
  42. package/src/templates/ship-panel.ts +145 -0
  43. package/src/tokens/colors.ts +45 -0
  44. package/src/tokens/index.ts +7 -0
  45. package/src/tokens/spacing.ts +10 -0
  46. package/src/tokens/typography.ts +19 -0
  47. package/test/__image_snapshots__/.gitkeep +0 -0
  48. package/test/__image_snapshots__/component-hull-plates.png +0 -0
  49. package/test/__image_snapshots__/module-engine-t1.png +0 -0
  50. package/test/__image_snapshots__/module-storage-t1.png +0 -0
  51. package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.png +0 -0
  52. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
  53. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.png +0 -0
  54. package/test/__image_snapshots__/resource-iron.diff.png +0 -0
  55. package/test/__image_snapshots__/resource-iron.png +0 -0
  56. package/test/__snapshots__/templates-component.test.ts.snap +5 -0
  57. package/test/__snapshots__/templates-item-cell.test.ts.snap +9 -0
  58. package/test/__snapshots__/templates-module.test.ts.snap +17 -0
  59. package/test/__snapshots__/templates-packed-entity.test.ts.snap +5 -0
  60. package/test/__snapshots__/templates-resource.test.ts.snap +7 -0
  61. package/test/base64url.test.ts +33 -0
  62. package/test/codec.test.ts +43 -0
  63. package/test/errors.test.ts +24 -0
  64. package/test/fixtures/cargo-items.ts +122 -0
  65. package/test/fonts.test.ts +28 -0
  66. package/test/links-meta.test.ts +34 -0
  67. package/test/pixel.test.ts +66 -0
  68. package/test/primitives-category-icon.test.ts +79 -0
  69. package/test/primitives-compact-row.test.ts +44 -0
  70. package/test/primitives-domain.test.ts +72 -0
  71. package/test/primitives-layout.test.ts +56 -0
  72. package/test/primitives-module-slot.test.ts +88 -0
  73. package/test/render.test.ts +40 -0
  74. package/test/sanity.test.ts +6 -0
  75. package/test/sdk-link.test.ts +19 -0
  76. package/test/snapshots/.gitkeep +0 -0
  77. package/test/svg.test.ts +28 -0
  78. package/test/templates-component.test.ts +36 -0
  79. package/test/templates-dispatch.test.ts +35 -0
  80. package/test/templates-item-cell.test.ts +94 -0
  81. package/test/templates-module.test.ts +63 -0
  82. package/test/templates-packed-entity.test.ts +47 -0
  83. package/test/templates-resource.test.ts +71 -0
  84. package/test/templates-ship-panel.test.ts +87 -0
  85. package/test/tokens.test.ts +32 -0
  86. package/tsconfig.json +20 -0
@@ -0,0 +1,139 @@
1
+ import type { ResolvedItem } from '@shipload/sdk'
2
+ import { getComponentById, getStatDefinitions, categoryColors } from '@shipload/sdk'
3
+ import type { CargoItem } from '../payload/codec.ts'
4
+ import { panel } from '../primitives/panel.ts'
5
+ import { iconHex } from '../primitives/icon-hex.ts'
6
+ import { text } from '../primitives/text.ts'
7
+ import { divider } from '../primitives/divider.ts'
8
+ import { statBar } from '../primitives/stat-bar.ts'
9
+ import { quantityBadge } from '../primitives/quantity-badge.ts'
10
+ import { tokens } from '../tokens/index.ts'
11
+ import { shortCode, formatMass, tierBorder } from './_shared.ts'
12
+
13
+ export interface RenderComponentOpts {
14
+ mode?: 'values' | 'ranges'
15
+ }
16
+
17
+ type StatRow = {
18
+ label: string
19
+ abbreviation: string
20
+ value: number | null
21
+ color: string
22
+ inverted?: boolean
23
+ }
24
+
25
+ export function renderComponent(
26
+ item: CargoItem,
27
+ resolved: ResolvedItem,
28
+ opts?: RenderComponentOpts,
29
+ ): string {
30
+ const mode = opts?.mode ?? 'values'
31
+ const w = tokens.spacing.panelWidth
32
+ const pad = tokens.spacing.panelPadding
33
+ const innerW = w - pad * 2
34
+
35
+ let rows: StatRow[]
36
+ if (mode === 'values') {
37
+ rows = (resolved.stats ?? []).map(s => ({
38
+ label: s.label,
39
+ abbreviation: s.abbreviation,
40
+ value: s.value,
41
+ color: s.color,
42
+ inverted: s.inverted,
43
+ }))
44
+ } else {
45
+ const comp = getComponentById(resolved.itemId)
46
+ rows = (comp?.stats ?? []).flatMap(stat => {
47
+ const defs = getStatDefinitions(stat.source)
48
+ const def = defs.find(d => d.key === stat.key)
49
+ if (!def) return []
50
+ return [{
51
+ label: def.label,
52
+ abbreviation: def.abbreviation,
53
+ value: null,
54
+ color: categoryColors[stat.source],
55
+ inverted: def.inverted,
56
+ }]
57
+ })
58
+ }
59
+
60
+ const headerH = 48
61
+ const metaRowH = 28
62
+ const statsH = rows.length * 26 + 8
63
+ const height = headerH + metaRowH + 14 + statsH + pad
64
+
65
+ const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
66
+
67
+ const quantity = Number(BigInt(item.quantity.toString()))
68
+ const badge = quantityBadge({ x: w - pad, y: pad, quantity })
69
+
70
+ const icon = iconHex({
71
+ x: pad,
72
+ y: pad + 4,
73
+ color: tokens.colors.accent.component,
74
+ code: shortCode(resolved.itemId),
75
+ })
76
+
77
+ const name = text({
78
+ x: pad + 34,
79
+ y: pad + 22,
80
+ value: resolved.name,
81
+ size: tokens.typography.sizes.title,
82
+ weight: 700,
83
+ family: tokens.typography.display,
84
+ })
85
+
86
+ const tierNum = resolved.tier.replace(/^t/i, '')
87
+ const subtitleText = text({
88
+ x: pad,
89
+ y: pad + headerH + 4,
90
+ value: 'Type',
91
+ size: tokens.typography.sizes.body,
92
+ color: tokens.colors.text.secondary,
93
+ })
94
+ const subtitleValue = text({
95
+ x: w - pad,
96
+ y: pad + headerH + 4,
97
+ value: `COMPONENT · T${tierNum}`,
98
+ size: tokens.typography.sizes.body,
99
+ weight: 600,
100
+ anchor: 'end',
101
+ })
102
+ const massLabel = text({
103
+ x: pad,
104
+ y: pad + headerH + metaRowH - 8,
105
+ value: 'Mass',
106
+ size: tokens.typography.sizes.body,
107
+ color: tokens.colors.text.secondary,
108
+ })
109
+ const massValue = text({
110
+ x: w - pad,
111
+ y: pad + headerH + metaRowH - 8,
112
+ value: formatMass(resolved.mass),
113
+ size: tokens.typography.sizes.body,
114
+ weight: 600,
115
+ anchor: 'end',
116
+ })
117
+
118
+ const sepY = pad + headerH + metaRowH + 6
119
+ const sep = divider({ x: pad, y: sepY, width: innerW })
120
+
121
+ const statsSvg = rows
122
+ .map((row, i) =>
123
+ statBar({
124
+ x: pad,
125
+ y: sepY + 18 + i * 26,
126
+ width: innerW,
127
+ label: row.label,
128
+ abbreviation: row.abbreviation,
129
+ value: row.value,
130
+ color: row.color,
131
+ inverted: row.inverted,
132
+ }),
133
+ )
134
+ .join('')
135
+
136
+ const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
137
+
138
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
139
+ }
@@ -0,0 +1,30 @@
1
+ import type { ResolvedItem } from '@shipload/sdk'
2
+ import type { CargoItem } from '../payload/codec.ts'
3
+ import { RenderError } from '../errors.ts'
4
+ import { renderResource } from './resource.ts'
5
+ import { renderPackedEntity } from './packed-entity.ts'
6
+ import { renderComponent } from './component.ts'
7
+ import { renderModule } from './module.ts'
8
+
9
+ export interface RenderByTypeOpts {
10
+ mode?: 'values' | 'ranges'
11
+ }
12
+
13
+ export function renderByType(
14
+ item: CargoItem,
15
+ resolved: ResolvedItem,
16
+ opts?: RenderByTypeOpts,
17
+ ): string {
18
+ switch (resolved.itemType) {
19
+ case 'resource':
20
+ return renderResource(item, resolved, opts)
21
+ case 'entity':
22
+ return renderPackedEntity(item, resolved)
23
+ case 'component':
24
+ return renderComponent(item, resolved, opts)
25
+ case 'module':
26
+ return renderModule(item, resolved, opts)
27
+ default:
28
+ throw new RenderError(`unknown itemType '${String(resolved.itemType)}'`)
29
+ }
30
+ }
@@ -0,0 +1,96 @@
1
+ import type { ResolvedItem } from '@shipload/sdk'
2
+ import { tierColors, categoryColors, categoryIconShapes } from '@shipload/sdk'
3
+ import { el } from '../primitives/svg.ts'
4
+ import { text } from '../primitives/text.ts'
5
+ import { categoryIconPath } from '../primitives/category-icon.ts'
6
+ import { tokens } from '../tokens/index.ts'
7
+
8
+ export interface ItemCellProps {
9
+ resolved: ResolvedItem
10
+ quantity?: number
11
+ size?: number
12
+ }
13
+
14
+ export interface ItemCellGroupProps extends ItemCellProps {
15
+ x: number
16
+ y: number
17
+ }
18
+
19
+ function cellInner(props: ItemCellProps): string {
20
+ const size = props.size ?? 48
21
+ const height = Math.round(size * 1.25)
22
+ const r = Math.max(4, Math.round(size * 0.12))
23
+ const cx = size / 2
24
+
25
+ const border = el('rect', {
26
+ x: 0.5,
27
+ y: 0.5,
28
+ width: size - 1,
29
+ height: height - 1,
30
+ rx: r,
31
+ ry: r,
32
+ fill: tokens.colors.surface.panel,
33
+ stroke: tierColors[props.resolved.tier],
34
+ 'stroke-width': 1.5,
35
+ })
36
+
37
+ let content = ''
38
+ if (props.resolved.abbreviation) {
39
+ const iconCy = size * 0.45
40
+ content = text({
41
+ x: cx,
42
+ y: iconCy,
43
+ value: props.resolved.abbreviation,
44
+ size: Math.round(size * 0.28),
45
+ weight: 700,
46
+ anchor: 'middle',
47
+ color: tokens.colors.text.primary,
48
+ family: tokens.typography.display,
49
+ })
50
+ } else if (props.resolved.category) {
51
+ const shape = categoryIconShapes[props.resolved.category]
52
+ const color = categoryColors[props.resolved.category]
53
+ const iconCy = size * 0.4
54
+ content = categoryIconPath({ shape, cx, cy: iconCy, size: size * 0.32, color, strokeWidth: 1.5 })
55
+ } else if (props.resolved.icon) {
56
+ content = text({
57
+ x: cx,
58
+ y: size * 0.4,
59
+ value: props.resolved.icon,
60
+ size: Math.round(size * 0.44),
61
+ weight: 400,
62
+ anchor: 'middle',
63
+ dominantBaseline: 'central',
64
+ color: tokens.colors.text.primary,
65
+ })
66
+ }
67
+
68
+ const qty = props.quantity ?? 0
69
+ let quantityText = ''
70
+ if (qty > 1) {
71
+ const qtyFontSize = Math.max(9, Math.round(size * 0.22))
72
+ const qtyPad = Math.max(3, Math.round(size * 0.12))
73
+ quantityText = text({
74
+ x: size - qtyPad,
75
+ y: height - qtyPad,
76
+ value: String(qty),
77
+ size: qtyFontSize,
78
+ weight: 700,
79
+ anchor: 'end',
80
+ color: tokens.colors.text.primary,
81
+ family: tokens.typography.display,
82
+ })
83
+ }
84
+
85
+ return border + content + quantityText
86
+ }
87
+
88
+ export function itemCellGroup(props: ItemCellGroupProps): string {
89
+ return `<g transform="translate(${props.x}, ${props.y})">${cellInner(props)}</g>`
90
+ }
91
+
92
+ export function renderItemCell(props: ItemCellProps): string {
93
+ const size = props.size ?? 48
94
+ const height = Math.round(size * 1.25)
95
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${height}" viewBox="0 0 ${size} ${height}">${cellInner(props)}</svg>`
96
+ }
@@ -0,0 +1,190 @@
1
+ import type { ResolvedItem } from '@shipload/sdk'
2
+ import { describeModuleForItem, renderDescription } from '@shipload/sdk'
3
+ import type { CargoItem } from '../payload/codec.ts'
4
+ import { panel } from '../primitives/panel.ts'
5
+ import { iconHex } from '../primitives/icon-hex.ts'
6
+ import { text } from '../primitives/text.ts'
7
+ import { divider } from '../primitives/divider.ts'
8
+ import { compactRow } from '../primitives/compact-row.ts'
9
+ import { quantityBadge } from '../primitives/quantity-badge.ts'
10
+ import { spanParagraph } from '../primitives/span-paragraph.ts'
11
+ import { tokens } from '../tokens/index.ts'
12
+ import { shortCode, formatMass, tierBorder } from './_shared.ts'
13
+
14
+ function capabilityColor(name: string): string {
15
+ const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
16
+ return tokens.colors.capability[key] ?? tokens.colors.accent.component
17
+ }
18
+
19
+ export interface RenderModuleOpts {
20
+ mode?: 'values' | 'ranges'
21
+ }
22
+
23
+ export function renderModule(item: CargoItem, resolved: ResolvedItem, opts?: RenderModuleOpts): string {
24
+ const mode = opts?.mode ?? 'values'
25
+ const w = tokens.spacing.panelWidth
26
+ const pad = tokens.spacing.panelPadding
27
+ const innerW = w - pad * 2
28
+
29
+ const group = resolved.attributes?.[0]
30
+ const attrs = group?.attributes ?? []
31
+ const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
32
+
33
+ const capabilityName =
34
+ group?.capability ??
35
+ resolved.name.replace(/\s+T\d+$/i, '')
36
+
37
+ const headerH = 48
38
+ const metaRowH = 28
39
+ const sepY = pad + headerH + metaRowH + 6
40
+
41
+ let bodyHeight = 0
42
+ if (mode === 'ranges') {
43
+ bodyHeight = 20 + 8
44
+ } else if (desc && group) {
45
+ const plain = renderDescription(desc)
46
+ .map((s) => s.text)
47
+ .join('')
48
+ const lines = plain.split(/\s+/).reduce(
49
+ (acc, word) => {
50
+ const last = acc[acc.length - 1] ?? ''
51
+ if (last.length === 0) return [...acc.slice(0, -1), word]
52
+ if (last.length + 1 + word.length <= 36) return [...acc.slice(0, -1), `${last} ${word}`]
53
+ return [...acc, word]
54
+ },
55
+ [''],
56
+ )
57
+ const lineCount = lines.filter((l) => l.length > 0).length
58
+ bodyHeight = 20 + lineCount * 14 + 8
59
+ } else if (group && attrs.length > 0) {
60
+ const capHeaderH = 22
61
+ const attrsH = attrs.length * 18
62
+ bodyHeight = capHeaderH + attrsH + 8
63
+ }
64
+
65
+ const height = headerH + metaRowH + 14 + bodyHeight + pad
66
+
67
+ const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
68
+
69
+ const quantity = Number(BigInt(item.quantity.toString()))
70
+ const badge = quantityBadge({ x: w - pad, y: pad, quantity })
71
+
72
+ const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
73
+ const icon = iconHex({
74
+ x: pad,
75
+ y: pad + 4,
76
+ color: iconColor,
77
+ code: shortCode(resolved.itemId),
78
+ })
79
+
80
+ const name = text({
81
+ x: pad + 34,
82
+ y: pad + 22,
83
+ value: resolved.name,
84
+ size: tokens.typography.sizes.title,
85
+ weight: 700,
86
+ family: tokens.typography.display,
87
+ })
88
+
89
+ const tierNum = resolved.tier.replace(/^t/i, '')
90
+ const subtitleLabel = text({
91
+ x: pad,
92
+ y: pad + headerH + 4,
93
+ value: 'Type',
94
+ size: tokens.typography.sizes.body,
95
+ color: tokens.colors.text.secondary,
96
+ })
97
+ const subtitleValue = text({
98
+ x: w - pad,
99
+ y: pad + headerH + 4,
100
+ value: `MODULE · T${tierNum}`,
101
+ size: tokens.typography.sizes.body,
102
+ weight: 600,
103
+ anchor: 'end',
104
+ })
105
+
106
+ const massLabel = text({
107
+ x: pad,
108
+ y: pad + headerH + metaRowH - 8,
109
+ value: 'Mass',
110
+ size: tokens.typography.sizes.body,
111
+ color: tokens.colors.text.secondary,
112
+ })
113
+ const massValue = text({
114
+ x: w - pad,
115
+ y: pad + headerH + metaRowH - 8,
116
+ value: formatMass(resolved.mass),
117
+ size: tokens.typography.sizes.body,
118
+ weight: 600,
119
+ anchor: 'end',
120
+ })
121
+
122
+ const sep = divider({ x: pad, y: sepY, width: innerW })
123
+
124
+ let capSection = ''
125
+ if (mode === 'ranges') {
126
+ const accentColor = capabilityColor(capabilityName)
127
+ capSection = text({
128
+ x: pad,
129
+ y: sepY + 16,
130
+ value: capabilityName.toUpperCase(),
131
+ size: tokens.typography.sizes.subtitle,
132
+ weight: 700,
133
+ family: tokens.typography.sans,
134
+ color: accentColor,
135
+ letterSpacing: 1,
136
+ })
137
+ } else if (desc && group) {
138
+ const accentColor = capabilityColor(group.capability)
139
+ const capHeader = text({
140
+ x: pad,
141
+ y: sepY + 16,
142
+ value: group.capability.toUpperCase(),
143
+ size: tokens.typography.sizes.subtitle,
144
+ weight: 700,
145
+ family: tokens.typography.sans,
146
+ color: accentColor,
147
+ letterSpacing: 1,
148
+ })
149
+ const spans = renderDescription(desc)
150
+ const { svg: paraSvg } = spanParagraph({
151
+ x: pad,
152
+ y: sepY + 36,
153
+ spans,
154
+ charsPerLine: 36,
155
+ lineHeight: 14,
156
+ })
157
+ capSection = capHeader + paraSvg
158
+ } else if (group && attrs.length > 0) {
159
+ const capY = sepY + 22
160
+ const capHeader = text({
161
+ x: pad,
162
+ y: capY,
163
+ value: group.capability.toUpperCase(),
164
+ size: 10,
165
+ weight: 700,
166
+ family: tokens.typography.sans,
167
+ color: capabilityColor(group.capability),
168
+ letterSpacing: 0.8,
169
+ })
170
+
171
+ const attrRows = attrs
172
+ .map((attr, i) => {
173
+ const displayValue = String(attr.value)
174
+ return compactRow({
175
+ x: pad,
176
+ y: capY + 14 + i * 18,
177
+ width: innerW,
178
+ label: attr.label,
179
+ value: displayValue,
180
+ })
181
+ })
182
+ .join('')
183
+
184
+ capSection = capHeader + attrRows
185
+ }
186
+
187
+ const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
188
+
189
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
190
+ }
@@ -0,0 +1,30 @@
1
+ import type { ResolvedItem, ResolvedModuleSlot } from '@shipload/sdk'
2
+ import { describeModuleForSlot, renderDescription } from '@shipload/sdk'
3
+ import type { CargoItem } from '../payload/codec.ts'
4
+ import { renderShipPanel, type ShipPanelSlot } from './ship-panel.ts'
5
+
6
+ function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
7
+ if (!slot.installed || !slot.attributes || !slot.name) {
8
+ return { installed: false }
9
+ }
10
+ const desc = describeModuleForSlot(slot)
11
+ if (desc) {
12
+ return { name: slot.name, installed: true, description: renderDescription(desc) }
13
+ }
14
+ const shorthand = slot.attributes
15
+ .map((a) => `${a.value} ${a.label.toLowerCase()}`)
16
+ .join(' · ')
17
+ return { name: slot.name, installed: true, description: shorthand }
18
+ }
19
+
20
+ export function renderPackedEntity(item: CargoItem, resolved: ResolvedItem): string {
21
+ const quantity = Number(BigInt(item.quantity.toString()))
22
+ const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
23
+ return renderShipPanel({
24
+ name: `${resolved.name} (Packed)`,
25
+ tier: resolved.tier,
26
+ quantity,
27
+ attributes: resolved.attributes ?? [],
28
+ slots,
29
+ })
30
+ }
@@ -0,0 +1,151 @@
1
+ import type { ResolvedItem } from '@shipload/sdk'
2
+ import { getStatDefinitions, categoryColors } from '@shipload/sdk'
3
+ import type { CargoItem } from '../payload/codec.ts'
4
+ import { panel } from '../primitives/panel.ts'
5
+ import { iconHex } from '../primitives/icon-hex.ts'
6
+ import { text } from '../primitives/text.ts'
7
+ import { divider } from '../primitives/divider.ts'
8
+ import { statBar } from '../primitives/stat-bar.ts'
9
+ import { quantityBadge } from '../primitives/quantity-badge.ts'
10
+ import { tokens } from '../tokens/index.ts'
11
+ import { shortCode, formatMass, tierBorder } from './_shared.ts'
12
+
13
+ const CATEGORY_LABELS: Record<string, string> = {
14
+ metal: 'Metals',
15
+ gas: 'Gas',
16
+ mineral: 'Minerals',
17
+ organic: 'Organic',
18
+ precious: 'Precious',
19
+ }
20
+
21
+ function categoryColor(category?: string): string {
22
+ if (!category) return tokens.colors.text.muted
23
+ const key = category as keyof typeof tokens.colors.category
24
+ return tokens.colors.category[key] ?? tokens.colors.text.muted
25
+ }
26
+
27
+ export interface RenderResourceOpts {
28
+ mode?: 'values' | 'ranges'
29
+ }
30
+
31
+ type StatRow = {
32
+ label: string
33
+ abbreviation: string
34
+ value: number | null
35
+ color: string
36
+ inverted?: boolean
37
+ }
38
+
39
+ export function renderResource(
40
+ item: CargoItem,
41
+ resolved: ResolvedItem,
42
+ opts?: RenderResourceOpts,
43
+ ): string {
44
+ const mode = opts?.mode ?? 'values'
45
+ const w = tokens.spacing.panelWidth
46
+ const pad = tokens.spacing.panelPadding
47
+ const innerW = w - pad * 2
48
+
49
+ let rows: StatRow[]
50
+ if (mode === 'values') {
51
+ rows = (resolved.stats ?? []).map(s => ({
52
+ label: s.label,
53
+ abbreviation: s.abbreviation,
54
+ value: s.value,
55
+ color: s.color,
56
+ inverted: s.inverted,
57
+ }))
58
+ } else {
59
+ const defs = resolved.category ? getStatDefinitions(resolved.category) : []
60
+ const color = resolved.category
61
+ ? categoryColors[resolved.category]
62
+ : tokens.colors.text.muted
63
+ rows = defs.map(d => ({
64
+ label: d.label,
65
+ abbreviation: d.abbreviation,
66
+ value: null,
67
+ color,
68
+ inverted: d.inverted,
69
+ }))
70
+ }
71
+
72
+ const headerH = 48
73
+ const metaRowH = 28
74
+ const statsH = rows.length * 26 + 8
75
+ const height = headerH + metaRowH + 14 + statsH + pad
76
+
77
+ const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
78
+
79
+ const quantity = Number(BigInt(item.quantity.toString()))
80
+ const badge = quantityBadge({ x: w - pad, y: pad, quantity })
81
+
82
+ const icon = iconHex({
83
+ x: pad,
84
+ y: pad + 4,
85
+ color: categoryColor(resolved.category),
86
+ code: shortCode(resolved.itemId),
87
+ })
88
+
89
+ const name = text({
90
+ x: pad + 34,
91
+ y: pad + 22,
92
+ value: resolved.name,
93
+ size: tokens.typography.sizes.title,
94
+ weight: 700,
95
+ family: tokens.typography.display,
96
+ })
97
+
98
+ const catLabel = CATEGORY_LABELS[(resolved.category ?? 'metal') as string] ?? 'Item'
99
+ const catText = text({
100
+ x: pad,
101
+ y: pad + headerH + 4,
102
+ value: 'Category',
103
+ size: tokens.typography.sizes.body,
104
+ color: tokens.colors.text.secondary,
105
+ })
106
+ const catValue = text({
107
+ x: w - pad,
108
+ y: pad + headerH + 4,
109
+ value: catLabel,
110
+ size: tokens.typography.sizes.body,
111
+ weight: 600,
112
+ anchor: 'end',
113
+ })
114
+ const massLabel = text({
115
+ x: pad,
116
+ y: pad + headerH + metaRowH - 8,
117
+ value: 'Mass',
118
+ size: tokens.typography.sizes.body,
119
+ color: tokens.colors.text.secondary,
120
+ })
121
+ const massValue = text({
122
+ x: w - pad,
123
+ y: pad + headerH + metaRowH - 8,
124
+ value: formatMass(resolved.mass),
125
+ size: tokens.typography.sizes.body,
126
+ weight: 600,
127
+ anchor: 'end',
128
+ })
129
+
130
+ const sepY = pad + headerH + metaRowH + 6
131
+ const sep = divider({ x: pad, y: sepY, width: innerW })
132
+
133
+ const statsSvg = rows
134
+ .map((row, i) =>
135
+ statBar({
136
+ x: pad,
137
+ y: sepY + 18 + i * 26,
138
+ width: innerW,
139
+ label: row.label,
140
+ abbreviation: row.abbreviation,
141
+ value: row.value,
142
+ color: row.color,
143
+ inverted: row.inverted,
144
+ }),
145
+ )
146
+ .join('')
147
+
148
+ const inner = `${chrome}${icon}${name}${badge}${catText}${catValue}${massLabel}${massValue}${sep}${statsSvg}`
149
+
150
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
151
+ }