@shipload/item-renderer 1.0.0-next.4 → 1.0.0-next.40

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.
@@ -1,17 +1,28 @@
1
- import type {ResolvedItem, ResourceCategory} from '@shipload/sdk'
2
- import {formatTier, getRecipe, getStatDefinitions, categoryColors} from '@shipload/sdk'
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {getRecipe, getStatDefinitions, resolveItemCategory, formatLocation} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {panel} from '../primitives/panel.ts'
5
5
  import {iconHex} from '../primitives/icon-hex.ts'
6
- import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
6
  import {statBar} from '../primitives/stat-bar.ts'
9
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
8
  import {tokens} from '../tokens/index.ts'
11
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
9
+ import {
10
+ shortCode,
11
+ formatMass,
12
+ tierBorder,
13
+ metaRowBlock,
14
+ titleText,
15
+ BADGE_Y,
16
+ HEADER_H,
17
+ ICON_Y,
18
+ STAT_BLOCK_GAP,
19
+ STAT_ROW_H,
20
+ BOTTOM_PAD,
21
+ } from './_shared.ts'
12
22
 
13
23
  export interface RenderComponentOpts {
14
24
  mode?: 'values' | 'ranges'
25
+ location?: {x: number; y: number}
15
26
  }
16
27
 
17
28
  type StatRow = {
@@ -32,13 +43,15 @@ export function renderComponent(
32
43
  const pad = tokens.spacing.panelPadding
33
44
  const innerW = w - pad * 2
34
45
 
46
+ const identity = tokens.colors.accent.component
47
+
35
48
  let rows: StatRow[]
36
49
  if (mode === 'values') {
37
50
  rows = (resolved.stats ?? []).map((s) => ({
38
51
  label: s.label,
39
52
  abbreviation: s.abbreviation,
40
53
  value: s.value,
41
- color: s.color,
54
+ color: identity,
42
55
  inverted: s.inverted,
43
56
  }))
44
57
  } else {
@@ -47,8 +60,9 @@ export function renderComponent(
47
60
  const src = slot.sources[0]
48
61
  if (!src) return []
49
62
  const input = recipe!.inputs[src.inputIndex]
50
- if (!input || !('category' in input)) return []
51
- const category = input.category as ResourceCategory
63
+ if (!input) return []
64
+ const category = resolveItemCategory(input.itemId)
65
+ if (!category) return []
52
66
  const def = getStatDefinitions(category)[src.statIndex]
53
67
  if (!def) return []
54
68
  return [
@@ -56,78 +70,44 @@ export function renderComponent(
56
70
  label: def.label,
57
71
  abbreviation: def.abbreviation,
58
72
  value: null,
59
- color: categoryColors[category],
73
+ color: identity,
60
74
  inverted: def.inverted,
61
75
  },
62
76
  ]
63
77
  })
64
78
  }
65
79
 
66
- const headerH = 48
67
- const metaRowH = 28
68
- const statsH = rows.length * 26 + 8
69
- const height = headerH + metaRowH + 14 + statsH + pad
80
+ const quantity = Number(BigInt(item.quantity.toString()))
81
+ const metaRows = [
82
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
83
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
84
+ ]
85
+
86
+ const metaYStart = pad + HEADER_H
87
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
88
+ const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
89
+ const statsBottom =
90
+ statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
91
+ const height = statsBottom + BOTTOM_PAD
70
92
 
71
93
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
72
94
 
73
- const quantity = Number(BigInt(item.quantity.toString()))
74
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
95
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: identity})
75
96
 
76
97
  const icon = iconHex({
77
98
  x: pad,
78
- y: pad + 4,
79
- color: tokens.colors.accent.component,
99
+ y: pad + ICON_Y,
100
+ color: identity,
80
101
  code: shortCode(resolved.itemId),
81
102
  })
82
103
 
83
- const name = text({
84
- x: pad + 34,
85
- y: pad + 22,
86
- value: resolved.name,
87
- size: tokens.typography.sizes.title,
88
- weight: 700,
89
- family: tokens.typography.display,
90
- })
91
-
92
- const subtitleText = text({
93
- x: pad,
94
- y: pad + headerH + 4,
95
- value: 'Type',
96
- size: tokens.typography.sizes.body,
97
- color: tokens.colors.text.secondary,
98
- })
99
- const subtitleValue = text({
100
- x: w - pad,
101
- y: pad + headerH + 4,
102
- value: `COMPONENT · ${formatTier(resolved.tier)}`,
103
- size: tokens.typography.sizes.body,
104
- weight: 600,
105
- anchor: 'end',
106
- })
107
- const massLabel = text({
108
- x: pad,
109
- y: pad + headerH + metaRowH - 8,
110
- value: 'Mass',
111
- size: tokens.typography.sizes.body,
112
- color: tokens.colors.text.secondary,
113
- })
114
- const massValue = text({
115
- x: w - pad,
116
- y: pad + headerH + metaRowH - 8,
117
- value: formatMass(resolved.mass),
118
- size: tokens.typography.sizes.body,
119
- weight: 600,
120
- anchor: 'end',
121
- })
122
-
123
- const sepY = pad + headerH + metaRowH + 6
124
- const sep = divider({x: pad, y: sepY, width: innerW})
104
+ const name = titleText(pad + 34, pad + 22, resolved)
125
105
 
126
106
  const statsSvg = rows
127
107
  .map((row, i) =>
128
108
  statBar({
129
109
  x: pad,
130
- y: sepY + 18 + i * 26,
110
+ y: statsYStart + i * STAT_ROW_H,
131
111
  width: innerW,
132
112
  label: row.label,
133
113
  abbreviation: row.abbreviation,
@@ -138,7 +118,7 @@ export function renderComponent(
138
118
  )
139
119
  .join('')
140
120
 
141
- const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
121
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
142
122
 
143
123
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
144
124
  }
@@ -8,6 +8,7 @@ import {renderModule} from './module.ts'
8
8
 
9
9
  export interface RenderByTypeOpts {
10
10
  mode?: 'values' | 'ranges'
11
+ location?: {x: number; y: number}
11
12
  }
12
13
 
13
14
  export function renderByType(
@@ -19,7 +20,7 @@ export function renderByType(
19
20
  case 'resource':
20
21
  return renderResource(item, resolved, opts)
21
22
  case 'entity':
22
- return renderPackedEntity(item, resolved)
23
+ return renderPackedEntity(item, resolved, opts)
23
24
  case 'component':
24
25
  return renderComponent(item, resolved, opts)
25
26
  case 'module':
@@ -1,14 +1,34 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {tierColors, categoryColors, categoryIconShapes} from '@shipload/sdk'
2
+ import {tierColors} from '@shipload/sdk'
3
3
  import {el} from '../primitives/svg.ts'
4
4
  import {text} from '../primitives/text.ts'
5
- import {categoryIconPath} from '../primitives/category-icon.ts'
5
+ import {componentIcon, componentIconSlugForName} from '../primitives/component-icon.ts'
6
+ import {entityIcon, entityIconSlugForName} from '../primitives/entity-icon.ts'
7
+ import {resourceIcon} from '../primitives/resource-icon.ts'
6
8
  import {tokens} from '../tokens/index.ts'
7
9
 
8
10
  export interface ItemCellProps {
9
11
  resolved: ResolvedItem
10
12
  quantity?: number
11
13
  size?: number
14
+ quantityColor?: string
15
+ quantityPrefix?: string
16
+ }
17
+
18
+ export function abbreviateQuantity(n: number): string {
19
+ const abs = Math.abs(n)
20
+ if (abs < 1000) return String(n)
21
+ const units = [
22
+ {v: 1e9, s: 'b'},
23
+ {v: 1e6, s: 'm'},
24
+ {v: 1e3, s: 'k'},
25
+ ]
26
+ for (const u of units) {
27
+ if (abs >= u.v) {
28
+ return (n / u.v).toFixed(1).replace(/\.0$/, '') + u.s
29
+ }
30
+ }
31
+ return String(n)
12
32
  }
13
33
 
14
34
  export interface ItemCellGroupProps extends ItemCellProps {
@@ -34,37 +54,70 @@ function cellInner(props: ItemCellProps): string {
34
54
  'stroke-width': 1.5,
35
55
  })
36
56
 
57
+ const qty = props.quantity ?? 0
58
+ const showQuantity = props.quantityPrefix ? qty >= 1 : qty > 1
59
+
37
60
  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,
61
+ const componentSlug =
62
+ props.resolved.itemType === 'component'
63
+ ? componentIconSlugForName(props.resolved.name)
64
+ : null
65
+ const entitySlug =
66
+ props.resolved.itemType === 'entity' ? entityIconSlugForName(props.resolved.name) : null
67
+
68
+ if (componentSlug) {
69
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
70
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
71
+ content = componentIcon(componentSlug, {
72
+ x: (size - iconSize) / 2,
73
+ y: iconY,
74
+ size: iconSize,
49
75
  })
76
+ } else if (entitySlug) {
77
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
78
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
79
+ content = entityIcon(entitySlug, {
80
+ x: (size - iconSize) / 2,
81
+ y: iconY,
82
+ size: iconSize,
83
+ })
84
+ } else if (props.resolved.abbreviation) {
85
+ content = showQuantity
86
+ ? text({
87
+ x: cx,
88
+ y: size * 0.45,
89
+ value: props.resolved.abbreviation,
90
+ size: Math.round(size * 0.28),
91
+ weight: 700,
92
+ anchor: 'middle',
93
+ color: tokens.colors.text.primary,
94
+ family: tokens.typography.display,
95
+ })
96
+ : text({
97
+ x: cx,
98
+ y: height / 2,
99
+ value: props.resolved.abbreviation,
100
+ size: Math.round(size * 0.36),
101
+ weight: 700,
102
+ anchor: 'middle',
103
+ dominantBaseline: 'central',
104
+ color: tokens.colors.text.primary,
105
+ family: tokens.typography.display,
106
+ })
50
107
  } 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({
55
- shape,
56
- cx,
57
- cy: iconCy,
58
- size: size * 0.32,
59
- color,
60
- strokeWidth: 1.5,
108
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
109
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
110
+ content = resourceIcon(props.resolved.category, {
111
+ x: (size - iconSize) / 2,
112
+ y: iconY,
113
+ size: iconSize,
61
114
  })
62
115
  } else if (props.resolved.icon) {
63
116
  content = text({
64
117
  x: cx,
65
- y: size * 0.4,
118
+ y: showQuantity ? size * 0.4 : height / 2,
66
119
  value: props.resolved.icon,
67
- size: Math.round(size * 0.44),
120
+ size: Math.round(size * (showQuantity ? 0.44 : 0.56)),
68
121
  weight: 400,
69
122
  anchor: 'middle',
70
123
  dominantBaseline: 'central',
@@ -72,21 +125,41 @@ function cellInner(props: ItemCellProps): string {
72
125
  })
73
126
  }
74
127
 
75
- const qty = props.quantity ?? 0
76
128
  let quantityText = ''
77
- if (qty > 1) {
78
- const qtyFontSize = Math.max(9, Math.round(size * 0.22))
79
- const qtyPad = Math.max(3, Math.round(size * 0.12))
80
- quantityText = text({
81
- x: size - qtyPad,
82
- y: height - qtyPad,
83
- value: String(qty),
84
- size: qtyFontSize,
85
- weight: 700,
86
- anchor: 'end',
87
- color: tokens.colors.text.primary,
88
- family: tokens.typography.display,
129
+ if (showQuantity) {
130
+ const label = (props.quantityPrefix ?? '') + abbreviateQuantity(qty)
131
+ const fontSize = Math.max(8, Math.round(size * 0.2))
132
+ const charW = fontSize * 0.6
133
+ const padX = Math.max(2, Math.round(fontSize * 0.34))
134
+ const padY = Math.max(1, Math.round(fontSize * 0.18))
135
+ const margin = Math.max(2, Math.round(size * 0.06))
136
+ const plateW = Math.round(label.length * charW) + padX * 2
137
+ const plateH = fontSize + padY * 2
138
+ const plateRight = size - margin
139
+ const plateBottom = height - margin
140
+ const plate = el('rect', {
141
+ x: plateRight - plateW,
142
+ y: plateBottom - plateH,
143
+ width: plateW,
144
+ height: plateH,
145
+ rx: Math.round(plateH / 2),
146
+ ry: Math.round(plateH / 2),
147
+ fill: '#050c24',
148
+ 'fill-opacity': 0.82,
89
149
  })
150
+ quantityText =
151
+ plate +
152
+ text({
153
+ x: plateRight - padX,
154
+ y: plateBottom - plateH / 2,
155
+ value: label,
156
+ size: fontSize,
157
+ weight: 700,
158
+ anchor: 'end',
159
+ dominantBaseline: 'central',
160
+ color: props.quantityColor ?? tokens.colors.text.primary,
161
+ family: tokens.typography.mono,
162
+ })
90
163
  }
91
164
 
92
165
  return border + content + quantityText
@@ -1,23 +1,30 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {describeModuleForItem, formatTier, renderDescription} from '@shipload/sdk'
2
+ import {describeModuleForItem, formatLocation, renderDescription} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {panel} from '../primitives/panel.ts'
5
5
  import {iconHex} from '../primitives/icon-hex.ts'
6
6
  import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
- import {compactRow} from '../primitives/compact-row.ts'
9
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
8
  import {spanParagraph} from '../primitives/span-paragraph.ts'
11
9
  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
- }
10
+ import {
11
+ shortCode,
12
+ formatMass,
13
+ tierBorder,
14
+ metaRowBlock,
15
+ titleText,
16
+ capabilityColor,
17
+ BADGE_Y,
18
+ HEADER_H,
19
+ ICON_Y,
20
+ META_BLOCK_GAP,
21
+ CAP_HEADER_H,
22
+ BODY_TAIL,
23
+ } from './_shared.ts'
18
24
 
19
25
  export interface RenderModuleOpts {
20
26
  mode?: 'values' | 'ranges'
27
+ location?: {x: number; y: number}
21
28
  }
22
29
 
23
30
  export function renderModule(
@@ -31,162 +38,69 @@ export function renderModule(
31
38
  const innerW = w - pad * 2
32
39
 
33
40
  const group = resolved.attributes?.[0]
34
- const attrs = group?.attributes ?? []
35
41
  const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
36
42
 
37
43
  const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
38
44
 
39
- const headerH = 48
40
- const metaRowH = 28
41
- const sepY = pad + headerH + metaRowH + 6
42
-
43
- let bodyHeight = 0
44
- if (mode === 'ranges') {
45
- bodyHeight = 20 + 8
46
- } else if (desc && group) {
47
- const plain = renderDescription(desc)
48
- .map((s) => s.text)
49
- .join('')
50
- const lines = plain.split(/\s+/).reduce(
51
- (acc, word) => {
52
- const last = acc[acc.length - 1] ?? ''
53
- if (last.length === 0) return [...acc.slice(0, -1), word]
54
- if (last.length + 1 + word.length <= 36)
55
- return [...acc.slice(0, -1), `${last} ${word}`]
56
- return [...acc, word]
57
- },
58
- ['']
59
- )
60
- const lineCount = lines.filter((l) => l.length > 0).length
61
- bodyHeight = 20 + lineCount * 14 + 8
62
- } else if (group && attrs.length > 0) {
63
- const capHeaderH = 22
64
- const attrsH = attrs.length * 18
65
- bodyHeight = capHeaderH + attrsH + 8
66
- }
67
-
68
- const height = headerH + metaRowH + 14 + bodyHeight + pad
69
-
70
- const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
71
-
72
45
  const quantity = Number(BigInt(item.quantity.toString()))
73
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
46
+ const metaRows = [
47
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
48
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
49
+ ]
74
50
 
75
- const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
76
- const icon = iconHex({
77
- x: pad,
78
- y: pad + 4,
79
- color: iconColor,
80
- code: shortCode(resolved.itemId),
81
- })
82
-
83
- const name = text({
84
- x: pad + 34,
85
- y: pad + 22,
86
- value: resolved.name,
87
- size: tokens.typography.sizes.title,
88
- weight: 700,
89
- family: tokens.typography.display,
90
- })
51
+ const metaYStart = pad + HEADER_H
52
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
53
+ const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
91
54
 
92
- const subtitleLabel = text({
93
- x: pad,
94
- y: pad + headerH + 4,
95
- value: 'Type',
96
- size: tokens.typography.sizes.body,
97
- color: tokens.colors.text.secondary,
98
- })
99
- const subtitleValue = text({
100
- x: w - pad,
101
- y: pad + headerH + 4,
102
- value: `MODULE · ${formatTier(resolved.tier)}`,
103
- size: tokens.typography.sizes.body,
104
- weight: 600,
105
- anchor: 'end',
106
- })
55
+ const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
107
56
 
108
- const massLabel = text({
57
+ const capLabel = (group?.capability ?? capabilityName).toUpperCase()
58
+ const capHeader = text({
109
59
  x: pad,
110
- y: pad + headerH + metaRowH - 8,
111
- value: 'Mass',
112
- size: tokens.typography.sizes.body,
113
- color: tokens.colors.text.secondary,
114
- })
115
- const massValue = text({
116
- x: w - pad,
117
- y: pad + headerH + metaRowH - 8,
118
- value: formatMass(resolved.mass),
119
- size: tokens.typography.sizes.body,
120
- weight: 600,
121
- anchor: 'end',
60
+ y: bodyYStart + 16,
61
+ value: capLabel,
62
+ size: tokens.typography.sizes.subtitle,
63
+ weight: 700,
64
+ family: tokens.typography.sans,
65
+ color: iconColor,
66
+ letterSpacing: 1,
122
67
  })
123
68
 
124
- const sep = divider({x: pad, y: sepY, width: innerW})
125
-
126
- let capSection = ''
127
- if (mode === 'ranges') {
128
- const accentColor = capabilityColor(capabilityName)
129
- capSection = text({
130
- x: pad,
131
- y: sepY + 16,
132
- value: capabilityName.toUpperCase(),
133
- size: tokens.typography.sizes.subtitle,
134
- weight: 700,
135
- family: tokens.typography.sans,
136
- color: accentColor,
137
- letterSpacing: 1,
138
- })
139
- } else if (desc && group) {
140
- const accentColor = capabilityColor(group.capability)
141
- const capHeader = text({
142
- x: pad,
143
- y: sepY + 16,
144
- value: group.capability.toUpperCase(),
145
- size: tokens.typography.sizes.subtitle,
146
- weight: 700,
147
- family: tokens.typography.sans,
148
- color: accentColor,
149
- letterSpacing: 1,
150
- })
69
+ let bodyHeight: number
70
+ let capSection: string
71
+ if (mode === 'values' && desc && group) {
151
72
  const spans = renderDescription(desc)
152
- const {svg: paraSvg} = spanParagraph({
73
+ const {svg: paraSvg, lineCount} = spanParagraph({
153
74
  x: pad,
154
- y: sepY + 36,
75
+ y: bodyYStart + 36,
155
76
  spans,
156
77
  charsPerLine: 36,
157
78
  lineHeight: 14,
79
+ highlightColor: tokens.colors.text.primary,
158
80
  })
81
+ bodyHeight = CAP_HEADER_H + lineCount * 14 + BODY_TAIL
159
82
  capSection = capHeader + paraSvg
160
- } else if (group && attrs.length > 0) {
161
- const capY = sepY + 22
162
- const capHeader = text({
163
- x: pad,
164
- y: capY,
165
- value: group.capability.toUpperCase(),
166
- size: 10,
167
- weight: 700,
168
- family: tokens.typography.sans,
169
- color: capabilityColor(group.capability),
170
- letterSpacing: 0.8,
171
- })
172
-
173
- const attrRows = attrs
174
- .map((attr, i) => {
175
- const displayValue = String(attr.value)
176
- return compactRow({
177
- x: pad,
178
- y: capY + 14 + i * 18,
179
- width: innerW,
180
- label: attr.label,
181
- value: displayValue,
182
- })
183
- })
184
- .join('')
185
-
186
- capSection = capHeader + attrRows
83
+ } else {
84
+ bodyHeight = CAP_HEADER_H + BODY_TAIL
85
+ capSection = capHeader
187
86
  }
188
87
 
189
- const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
88
+ const height = bodyYStart + bodyHeight + pad
89
+
90
+ const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
91
+
92
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: iconColor})
93
+
94
+ const icon = iconHex({
95
+ x: pad,
96
+ y: pad + ICON_Y,
97
+ color: iconColor,
98
+ code: shortCode(resolved.itemId),
99
+ })
100
+
101
+ const name = titleText(pad + 34, pad + 22, resolved)
102
+
103
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
190
104
 
191
105
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
192
106
  }
@@ -1,27 +1,47 @@
1
1
  import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
2
- import {describeModuleForSlot, renderDescription} from '@shipload/sdk'
2
+ import {baseName, describeModuleForSlot, renderDescription} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {renderShipPanel, type ShipPanelSlot} from './ship-panel.ts'
5
5
 
6
+ function capabilityFromName(name: string): string {
7
+ return name.replace(/\s+T\d+\s*$/i, '').trim()
8
+ }
9
+
6
10
  function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
7
11
  if (!slot.installed || !slot.attributes || !slot.name) {
8
12
  return {installed: false}
9
13
  }
14
+ const capability = capabilityFromName(slot.name)
10
15
  const desc = describeModuleForSlot(slot)
11
16
  if (desc) {
12
- return {name: slot.name, installed: true, description: renderDescription(desc)}
17
+ return {
18
+ name: slot.name,
19
+ installed: true,
20
+ capability,
21
+ description: renderDescription(desc),
22
+ }
13
23
  }
14
24
  const shorthand = slot.attributes.map((a) => `${a.value} ${a.label.toLowerCase()}`).join(' · ')
15
- return {name: slot.name, installed: true, description: shorthand}
25
+ return {name: slot.name, installed: true, capability, description: shorthand}
26
+ }
27
+
28
+ export interface RenderPackedEntityOpts {
29
+ mode?: 'values' | 'ranges'
30
+ location?: {x: number; y: number}
16
31
  }
17
32
 
18
- export function renderPackedEntity(item: CargoItem, resolved: ResolvedItem): string {
33
+ export function renderPackedEntity(
34
+ item: CargoItem,
35
+ resolved: ResolvedItem,
36
+ opts?: RenderPackedEntityOpts
37
+ ): string {
19
38
  const quantity = Number(BigInt(item.quantity.toString()))
20
39
  const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
21
40
  return renderShipPanel({
22
- name: `${resolved.name} (Packed)`,
41
+ name: baseName(resolved),
23
42
  tier: resolved.tier,
24
43
  quantity,
44
+ location: opts?.location,
25
45
  attributes: resolved.attributes ?? [],
26
46
  slots,
27
47
  })