@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.
package/src/render.ts CHANGED
@@ -1,29 +1,34 @@
1
1
  import {resolveItem, type ResolvedItem} from '@shipload/sdk'
2
2
  import type {CargoItem} from './payload/codec.ts'
3
- import {decodePayload} from './payload/codec.ts'
3
+ import {decodeNftPayload} from './payload/codec.ts'
4
4
  import {renderByType} from './templates/index.ts'
5
5
  import {UnknownItemError} from './errors.ts'
6
6
 
7
7
  export interface RenderOptions {
8
8
  width?: number
9
9
  theme?: 'dark' | 'light'
10
+ location?: {x: number; y: number}
10
11
  }
11
12
 
12
- export function renderItem(item: CargoItem, resolved: ResolvedItem, _opts?: RenderOptions): string {
13
- return renderByType(item, resolved)
13
+ export function renderItem(item: CargoItem, resolved: ResolvedItem, opts?: RenderOptions): string {
14
+ return renderByType(item, resolved, {location: opts?.location})
14
15
  }
15
16
 
16
17
  export async function renderFromPayload(
17
18
  payload: string,
18
19
  opts?: RenderOptions
19
20
  ): Promise<{svg: string; item: ResolvedItem}> {
20
- const cargoItem = decodePayload(payload)
21
+ const decoded = decodeNftPayload(payload)
22
+ const cargo = decoded.item
21
23
  let resolved: ResolvedItem
22
24
  try {
23
- resolved = resolveItem(cargoItem.item_id, cargoItem.stats, cargoItem.modules)
25
+ resolved = resolveItem(cargo.item_id, cargo.stats, cargo.modules)
24
26
  } catch {
25
- throw new UnknownItemError(Number(BigInt(cargoItem.item_id.toString())))
27
+ throw new UnknownItemError(Number(BigInt(cargo.item_id.toString())))
26
28
  }
27
- const svg = renderItem(cargoItem, resolved, opts)
29
+ const location = decoded.location
30
+ ? {x: Number(decoded.location.x), y: Number(decoded.location.y)}
31
+ : opts?.location
32
+ const svg = renderItem(cargo, resolved, {...opts, location})
28
33
  return {svg, item: resolved}
29
34
  }
@@ -1,7 +1,11 @@
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {baseName, formatMassScaled} from '@shipload/sdk'
3
+ import {text} from '../primitives/text.ts'
4
+ import {divider} from '../primitives/divider.ts'
1
5
  import {tokens} from '../tokens/index.ts'
2
6
 
3
- export function formatMass(n: number): string {
4
- return n.toLocaleString('en-US')
7
+ export function formatMass(kg: number): string {
8
+ return formatMassScaled(kg)
5
9
  }
6
10
 
7
11
  export function tierBorder(tier: number): string {
@@ -12,3 +16,110 @@ export function shortCode(itemId: number): string {
12
16
  const str = itemId.toString(10)
13
17
  return str.slice(-2).padStart(2, '0')
14
18
  }
19
+
20
+ export function capabilityColor(name: string): string {
21
+ const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
22
+ return tokens.colors.capability[key] ?? tokens.colors.accent.component
23
+ }
24
+
25
+ export const META_ROW_H = 22
26
+ export const HEADER_H = 48
27
+ export const ICON_Y = 4
28
+ export const BADGE_Y = 6
29
+ export const META_BLOCK_GAP = 16
30
+
31
+ export const STAT_ROW_H = 26
32
+ export const CAP_HEADER_H = 22
33
+ export const CAP_ROW_H = 18
34
+ export const BODY_TAIL = 8
35
+
36
+ // Gap from the meta block to the first stat row. Resources/components have no
37
+ // body sub-header, and statBar draws its label 6px above its y, so this is
38
+ // META_BLOCK_GAP plus the offset that puts the first stat label level with
39
+ // where module/entity body sections begin — keeping the meta→body gap uniform.
40
+ export const STAT_BLOCK_GAP = META_BLOCK_GAP + 22
41
+
42
+ // Uniform gap between a card's last body element and the bottom frame edge.
43
+ // Cards size their height to (last element bottom) + BOTTOM_PAD so trailing
44
+ // space is consistent across resource / component / module / entity types.
45
+ export const BOTTOM_PAD = 22
46
+
47
+ export function titleParts(x: number, y: number, name: string, tier: number): string {
48
+ return text({
49
+ x,
50
+ y,
51
+ value: name,
52
+ size: tokens.typography.sizes.title,
53
+ weight: 700,
54
+ family: tokens.typography.display,
55
+ spans: [
56
+ {
57
+ value: `T${tier}`,
58
+ dx: 6,
59
+ size: tokens.typography.sizes.subtitle,
60
+ weight: 700,
61
+ color: tokens.colors.text.secondary,
62
+ },
63
+ ],
64
+ })
65
+ }
66
+
67
+ export function titleText(x: number, y: number, resolved: ResolvedItem): string {
68
+ // Prominent base name; tier rendered as a smaller, muted inline suffix.
69
+ return titleParts(x, y, baseName(resolved), resolved.tier)
70
+ }
71
+
72
+ export interface MetaRowProps {
73
+ x: number
74
+ y: number
75
+ width: number
76
+ label: string
77
+ value: string
78
+ showDivider?: boolean
79
+ }
80
+
81
+ export function metaRow({x, y, width, label, value, showDivider = true}: MetaRowProps): string {
82
+ const labelText = text({
83
+ x,
84
+ y: y + 12,
85
+ value: label,
86
+ size: tokens.typography.sizes.body,
87
+ color: tokens.colors.text.secondary,
88
+ })
89
+ const valueText = text({
90
+ x: x + width,
91
+ y: y + 12,
92
+ value,
93
+ size: tokens.typography.sizes.body,
94
+ weight: 700,
95
+ anchor: 'end',
96
+ })
97
+ const sep = showDivider
98
+ ? divider({
99
+ x,
100
+ y: y + META_ROW_H - 4,
101
+ width,
102
+ color: tokens.colors.surface.panelBorderBright,
103
+ })
104
+ : ''
105
+ return labelText + valueText + sep
106
+ }
107
+
108
+ export function metaRowBlock(
109
+ x: number,
110
+ yStart: number,
111
+ width: number,
112
+ rows: {label: string; value: string}[]
113
+ ): {svg: string; height: number} {
114
+ let svg = ''
115
+ rows.forEach((row, i) => {
116
+ svg += metaRow({
117
+ x,
118
+ y: yStart + i * META_ROW_H,
119
+ width,
120
+ ...row,
121
+ showDivider: i < rows.length - 1,
122
+ })
123
+ })
124
+ return {svg, height: rows.length * META_ROW_H}
125
+ }
@@ -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,37 @@
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 {moduleIcon, moduleIconSlugForName} from '../primitives/module-icon.ts'
8
+ import {resourceIcon} from '../primitives/resource-icon.ts'
9
+ import {stationEntityIcon, stationEntityIconKindForName} from '../primitives/station-entity-icon.ts'
6
10
  import {tokens} from '../tokens/index.ts'
7
11
 
8
12
  export interface ItemCellProps {
9
13
  resolved: ResolvedItem
10
14
  quantity?: number
11
15
  size?: number
16
+ quantityColor?: string
17
+ quantityPrefix?: string
18
+ iconImageHref?: string
19
+ }
20
+
21
+ export function abbreviateQuantity(n: number): string {
22
+ const abs = Math.abs(n)
23
+ if (abs < 1000) return String(n)
24
+ const units = [
25
+ {v: 1e9, s: 'b'},
26
+ {v: 1e6, s: 'm'},
27
+ {v: 1e3, s: 'k'},
28
+ ]
29
+ for (const u of units) {
30
+ if (abs >= u.v) {
31
+ return (n / u.v).toFixed(1).replace(/\.0$/, '') + u.s
32
+ }
33
+ }
34
+ return String(n)
12
35
  }
13
36
 
14
37
  export interface ItemCellGroupProps extends ItemCellProps {
@@ -34,37 +57,103 @@ function cellInner(props: ItemCellProps): string {
34
57
  'stroke-width': 1.5,
35
58
  })
36
59
 
60
+ const qty = props.quantity ?? 0
61
+ const showQuantity = props.quantityPrefix ? qty >= 1 : qty > 1
62
+
37
63
  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,
64
+ const componentSlug =
65
+ props.resolved.itemType === 'component'
66
+ ? componentIconSlugForName(props.resolved.name)
67
+ : null
68
+ const stationEntityKind =
69
+ props.resolved.itemType === 'entity'
70
+ ? stationEntityIconKindForName(props.resolved.name)
71
+ : null
72
+ const entitySlug =
73
+ props.resolved.itemType === 'entity' ? entityIconSlugForName(props.resolved.name) : null
74
+ const moduleSlug =
75
+ props.resolved.itemType === 'module' ? moduleIconSlugForName(props.resolved.name) : null
76
+
77
+ if (props.iconImageHref) {
78
+ const iconSize = Math.round(size * (showQuantity ? 0.72 : 0.9))
79
+ const iconY = showQuantity ? Math.round(size * 0.08) : Math.round(height / 2 - iconSize / 2)
80
+ content = el('image', {
81
+ href: props.iconImageHref,
82
+ x: (size - iconSize) / 2,
83
+ y: iconY,
84
+ width: iconSize,
85
+ height: iconSize,
86
+ preserveAspectRatio: 'xMidYMid meet',
87
+ })
88
+ } else if (componentSlug) {
89
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
90
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
91
+ content = componentIcon(componentSlug, {
92
+ x: (size - iconSize) / 2,
93
+ y: iconY,
94
+ size: iconSize,
95
+ })
96
+ } else if (stationEntityKind) {
97
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
98
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
99
+ content = stationEntityIcon(stationEntityKind, {
100
+ x: (size - iconSize) / 2,
101
+ y: iconY,
102
+ size: iconSize,
49
103
  })
104
+ } else if (entitySlug) {
105
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
106
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
107
+ content = entityIcon(entitySlug, {
108
+ x: (size - iconSize) / 2,
109
+ y: iconY,
110
+ size: iconSize,
111
+ })
112
+ } else if (moduleSlug) {
113
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
114
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
115
+ content = moduleIcon(moduleSlug, {
116
+ x: (size - iconSize) / 2,
117
+ y: iconY,
118
+ size: iconSize,
119
+ })
120
+ } else if (props.resolved.abbreviation) {
121
+ content = showQuantity
122
+ ? text({
123
+ x: cx,
124
+ y: size * 0.45,
125
+ value: props.resolved.abbreviation,
126
+ size: Math.round(size * 0.28),
127
+ weight: 700,
128
+ anchor: 'middle',
129
+ color: tokens.colors.text.primary,
130
+ family: tokens.typography.display,
131
+ })
132
+ : text({
133
+ x: cx,
134
+ y: height / 2,
135
+ value: props.resolved.abbreviation,
136
+ size: Math.round(size * 0.36),
137
+ weight: 700,
138
+ anchor: 'middle',
139
+ dominantBaseline: 'central',
140
+ color: tokens.colors.text.primary,
141
+ family: tokens.typography.display,
142
+ })
50
143
  } 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,
144
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
145
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
146
+ content = resourceIcon(props.resolved.category, {
147
+ x: (size - iconSize) / 2,
148
+ y: iconY,
149
+ size: iconSize,
61
150
  })
62
151
  } else if (props.resolved.icon) {
63
152
  content = text({
64
153
  x: cx,
65
- y: size * 0.4,
154
+ y: showQuantity ? size * 0.4 : height / 2,
66
155
  value: props.resolved.icon,
67
- size: Math.round(size * 0.44),
156
+ size: Math.round(size * (showQuantity ? 0.44 : 0.56)),
68
157
  weight: 400,
69
158
  anchor: 'middle',
70
159
  dominantBaseline: 'central',
@@ -72,21 +161,41 @@ function cellInner(props: ItemCellProps): string {
72
161
  })
73
162
  }
74
163
 
75
- const qty = props.quantity ?? 0
76
164
  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,
165
+ if (showQuantity) {
166
+ const label = (props.quantityPrefix ?? '') + abbreviateQuantity(qty)
167
+ const fontSize = Math.max(8, Math.round(size * 0.2))
168
+ const charW = fontSize * 0.6
169
+ const padX = Math.max(2, Math.round(fontSize * 0.34))
170
+ const padY = Math.max(1, Math.round(fontSize * 0.18))
171
+ const margin = Math.max(2, Math.round(size * 0.06))
172
+ const plateW = Math.round(label.length * charW) + padX * 2
173
+ const plateH = fontSize + padY * 2
174
+ const plateRight = size - margin
175
+ const plateBottom = height - margin
176
+ const plate = el('rect', {
177
+ x: plateRight - plateW,
178
+ y: plateBottom - plateH,
179
+ width: plateW,
180
+ height: plateH,
181
+ rx: Math.round(plateH / 2),
182
+ ry: Math.round(plateH / 2),
183
+ fill: '#050c24',
184
+ 'fill-opacity': 0.82,
89
185
  })
186
+ quantityText =
187
+ plate +
188
+ text({
189
+ x: plateRight - padX,
190
+ y: plateBottom - plateH / 2,
191
+ value: label,
192
+ size: fontSize,
193
+ weight: 700,
194
+ anchor: 'end',
195
+ dominantBaseline: 'central',
196
+ color: props.quantityColor ?? tokens.colors.text.primary,
197
+ family: tokens.typography.mono,
198
+ })
90
199
  }
91
200
 
92
201
  return border + content + quantityText