@shipload/item-renderer 1.0.0-next.13 → 1.0.0-next.15

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.13",
3
+ "version": "1.0.0-next.15",
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": "^1.0.0-next.13",
48
+ "@shipload/sdk": "^1.0.0-next.15",
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'
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
+ }
@@ -9,7 +9,7 @@ export interface QuantityBadgeProps {
9
9
  }
10
10
 
11
11
  export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
12
- if (quantity <= 1) return ''
12
+ if (quantity <= 0) return ''
13
13
  const label = `×${quantity}`
14
14
  const w = label.length * 7 + 12
15
15
  const h = tokens.spacing.quantityBadgeHeight
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,10 @@
1
+ import {formatMassScaled} from '@shipload/sdk'
2
+ import {text} from '../primitives/text.ts'
3
+ import {divider} from '../primitives/divider.ts'
1
4
  import {tokens} from '../tokens/index.ts'
2
5
 
3
- export function formatMass(n: number): string {
4
- return n.toLocaleString('en-US')
6
+ export function formatMass(kg: number): string {
7
+ return formatMassScaled(kg)
5
8
  }
6
9
 
7
10
  export function tierBorder(tier: number): string {
@@ -12,3 +15,64 @@ export function shortCode(itemId: number): string {
12
15
  const str = itemId.toString(10)
13
16
  return str.slice(-2).padStart(2, '0')
14
17
  }
18
+
19
+ export const META_ROW_H = 22
20
+ export const HEADER_H = 48
21
+ export const ICON_Y = 4
22
+ export const BADGE_Y = 6
23
+ export const META_BLOCK_GAP = 16
24
+
25
+ export interface MetaRowProps {
26
+ x: number
27
+ y: number
28
+ width: number
29
+ label: string
30
+ value: string
31
+ showDivider?: boolean
32
+ }
33
+
34
+ export function metaRow({x, y, width, label, value, showDivider = true}: MetaRowProps): string {
35
+ const labelText = text({
36
+ x,
37
+ y: y + 12,
38
+ value: label,
39
+ size: tokens.typography.sizes.body,
40
+ color: tokens.colors.text.secondary,
41
+ })
42
+ const valueText = text({
43
+ x: x + width,
44
+ y: y + 12,
45
+ value,
46
+ size: tokens.typography.sizes.body,
47
+ weight: 700,
48
+ anchor: 'end',
49
+ })
50
+ const sep = showDivider
51
+ ? divider({
52
+ x,
53
+ y: y + META_ROW_H - 4,
54
+ width,
55
+ color: tokens.colors.surface.panelBorderBright,
56
+ })
57
+ : ''
58
+ return labelText + valueText + sep
59
+ }
60
+
61
+ export function metaRowBlock(
62
+ x: number,
63
+ yStart: number,
64
+ width: number,
65
+ rows: {label: string; value: string}[]
66
+ ): {svg: string; height: number} {
67
+ let svg = ''
68
+ rows.forEach((row, i) => {
69
+ svg += metaRow({
70
+ x,
71
+ y: yStart + i * META_ROW_H,
72
+ width,
73
+ ...row,
74
+ showDivider: i < rows.length - 1,
75
+ })
76
+ })
77
+ return {svg, height: rows.length * META_ROW_H}
78
+ }
@@ -1,17 +1,33 @@
1
1
  import type {ResolvedItem, ResourceCategory} from '@shipload/sdk'
2
- import {formatTier, getRecipe, getStatDefinitions, categoryColors} from '@shipload/sdk'
2
+ import {
3
+ formatTier,
4
+ getRecipe,
5
+ getStatDefinitions,
6
+ categoryColors,
7
+ displayName,
8
+ formatLocation,
9
+ } from '@shipload/sdk'
3
10
  import type {CargoItem} from '../payload/codec.ts'
4
11
  import {panel} from '../primitives/panel.ts'
5
12
  import {iconHex} from '../primitives/icon-hex.ts'
6
13
  import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
14
  import {statBar} from '../primitives/stat-bar.ts'
9
15
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
16
  import {tokens} from '../tokens/index.ts'
11
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
17
+ import {
18
+ shortCode,
19
+ formatMass,
20
+ tierBorder,
21
+ metaRowBlock,
22
+ BADGE_Y,
23
+ HEADER_H,
24
+ ICON_Y,
25
+ META_BLOCK_GAP,
26
+ } from './_shared.ts'
12
27
 
13
28
  export interface RenderComponentOpts {
14
29
  mode?: 'values' | 'ranges'
30
+ location?: {x: number; y: number}
15
31
  }
16
32
 
17
33
  type StatRow = {
@@ -63,19 +79,26 @@ export function renderComponent(
63
79
  })
64
80
  }
65
81
 
66
- const headerH = 48
67
- const metaRowH = 28
82
+ const metaRows = [
83
+ {label: 'Type', value: `COMPONENT · ${formatTier(resolved.tier)}`},
84
+ {label: 'Mass', value: formatMass(resolved.mass)},
85
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
86
+ ]
87
+
88
+ const metaYStart = pad + HEADER_H
89
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
90
+ const statsYStart = metaYStart + metaH + META_BLOCK_GAP
68
91
  const statsH = rows.length * 26 + 8
69
- const height = headerH + metaRowH + 14 + statsH + pad
92
+ const height = statsYStart + statsH + pad
70
93
 
71
94
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
72
95
 
73
96
  const quantity = Number(BigInt(item.quantity.toString()))
74
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
97
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
75
98
 
76
99
  const icon = iconHex({
77
100
  x: pad,
78
- y: pad + 4,
101
+ y: pad + ICON_Y,
79
102
  color: tokens.colors.accent.component,
80
103
  code: shortCode(resolved.itemId),
81
104
  })
@@ -83,51 +106,17 @@ export function renderComponent(
83
106
  const name = text({
84
107
  x: pad + 34,
85
108
  y: pad + 22,
86
- value: resolved.name,
109
+ value: displayName(resolved),
87
110
  size: tokens.typography.sizes.title,
88
111
  weight: 700,
89
112
  family: tokens.typography.display,
90
113
  })
91
114
 
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})
125
-
126
115
  const statsSvg = rows
127
116
  .map((row, i) =>
128
117
  statBar({
129
118
  x: pad,
130
- y: sepY + 18 + i * 26,
119
+ y: statsYStart + i * 26,
131
120
  width: innerW,
132
121
  label: row.label,
133
122
  abbreviation: row.abbreviation,
@@ -138,7 +127,7 @@ export function renderComponent(
138
127
  )
139
128
  .join('')
140
129
 
141
- const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
130
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
142
131
 
143
132
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
144
133
  }
@@ -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,15 +1,23 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {describeModuleForItem, formatTier, renderDescription} from '@shipload/sdk'
2
+ import {describeModuleForItem, displayName, 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
7
  import {compactRow} from '../primitives/compact-row.ts'
9
8
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
9
  import {spanParagraph} from '../primitives/span-paragraph.ts'
11
10
  import {tokens} from '../tokens/index.ts'
12
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
11
+ import {
12
+ shortCode,
13
+ formatMass,
14
+ tierBorder,
15
+ metaRowBlock,
16
+ BADGE_Y,
17
+ HEADER_H,
18
+ ICON_Y,
19
+ META_BLOCK_GAP,
20
+ } from './_shared.ts'
13
21
 
14
22
  function capabilityColor(name: string): string {
15
23
  const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
@@ -18,6 +26,7 @@ function capabilityColor(name: string): string {
18
26
 
19
27
  export interface RenderModuleOpts {
20
28
  mode?: 'values' | 'ranges'
29
+ location?: {x: number; y: number}
21
30
  }
22
31
 
23
32
  export function renderModule(
@@ -36,9 +45,14 @@ export function renderModule(
36
45
 
37
46
  const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
38
47
 
39
- const headerH = 48
40
- const metaRowH = 28
41
- const sepY = pad + headerH + metaRowH + 6
48
+ const metaRows = [
49
+ {label: 'Mass', value: formatMass(resolved.mass)},
50
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
51
+ ]
52
+
53
+ const metaYStart = pad + HEADER_H
54
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
55
+ const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
42
56
 
43
57
  let bodyHeight = 0
44
58
  if (mode === 'ranges') {
@@ -65,17 +79,17 @@ export function renderModule(
65
79
  bodyHeight = capHeaderH + attrsH + 8
66
80
  }
67
81
 
68
- const height = headerH + metaRowH + 14 + bodyHeight + pad
82
+ const height = bodyYStart + bodyHeight + pad
69
83
 
70
84
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
71
85
 
72
86
  const quantity = Number(BigInt(item.quantity.toString()))
73
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
87
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
74
88
 
75
89
  const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
76
90
  const icon = iconHex({
77
91
  x: pad,
78
- y: pad + 4,
92
+ y: pad + ICON_Y,
79
93
  color: iconColor,
80
94
  code: shortCode(resolved.itemId),
81
95
  })
@@ -83,52 +97,18 @@ export function renderModule(
83
97
  const name = text({
84
98
  x: pad + 34,
85
99
  y: pad + 22,
86
- value: resolved.name,
100
+ value: displayName(resolved),
87
101
  size: tokens.typography.sizes.title,
88
102
  weight: 700,
89
103
  family: tokens.typography.display,
90
104
  })
91
105
 
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
- })
107
-
108
- const massLabel = text({
109
- 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',
122
- })
123
-
124
- const sep = divider({x: pad, y: sepY, width: innerW})
125
-
126
106
  let capSection = ''
127
107
  if (mode === 'ranges') {
128
108
  const accentColor = capabilityColor(capabilityName)
129
109
  capSection = text({
130
110
  x: pad,
131
- y: sepY + 16,
111
+ y: bodyYStart + 16,
132
112
  value: capabilityName.toUpperCase(),
133
113
  size: tokens.typography.sizes.subtitle,
134
114
  weight: 700,
@@ -140,7 +120,7 @@ export function renderModule(
140
120
  const accentColor = capabilityColor(group.capability)
141
121
  const capHeader = text({
142
122
  x: pad,
143
- y: sepY + 16,
123
+ y: bodyYStart + 16,
144
124
  value: group.capability.toUpperCase(),
145
125
  size: tokens.typography.sizes.subtitle,
146
126
  weight: 700,
@@ -151,14 +131,14 @@ export function renderModule(
151
131
  const spans = renderDescription(desc)
152
132
  const {svg: paraSvg} = spanParagraph({
153
133
  x: pad,
154
- y: sepY + 36,
134
+ y: bodyYStart + 36,
155
135
  spans,
156
136
  charsPerLine: 36,
157
137
  lineHeight: 14,
158
138
  })
159
139
  capSection = capHeader + paraSvg
160
140
  } else if (group && attrs.length > 0) {
161
- const capY = sepY + 22
141
+ const capY = bodyYStart + 22
162
142
  const capHeader = text({
163
143
  x: pad,
164
144
  y: capY,
@@ -186,7 +166,7 @@ export function renderModule(
186
166
  capSection = capHeader + attrRows
187
167
  }
188
168
 
189
- const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
169
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
190
170
 
191
171
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
192
172
  }
@@ -1,5 +1,5 @@
1
1
  import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
2
- import {describeModuleForSlot, renderDescription} from '@shipload/sdk'
2
+ import {describeModuleForSlot, displayName, 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
 
@@ -15,13 +15,23 @@ function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
15
15
  return {name: slot.name, installed: true, description: shorthand}
16
16
  }
17
17
 
18
- export function renderPackedEntity(item: CargoItem, resolved: ResolvedItem): string {
18
+ export interface RenderPackedEntityOpts {
19
+ mode?: 'values' | 'ranges'
20
+ location?: {x: number; y: number}
21
+ }
22
+
23
+ export function renderPackedEntity(
24
+ item: CargoItem,
25
+ resolved: ResolvedItem,
26
+ opts?: RenderPackedEntityOpts
27
+ ): string {
19
28
  const quantity = Number(BigInt(item.quantity.toString()))
20
29
  const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
21
30
  return renderShipPanel({
22
- name: `${resolved.name} (Packed)`,
31
+ name: `${displayName(resolved)} (Packed)`,
23
32
  tier: resolved.tier,
24
33
  quantity,
34
+ location: opts?.location,
25
35
  attributes: resolved.attributes ?? [],
26
36
  slots,
27
37
  })
@@ -1,22 +1,22 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {getStatDefinitions, categoryColors, displayName} from '@shipload/sdk'
2
+ import {getStatDefinitions, categoryColors, displayName, 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
6
  import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
7
  import {statBar} from '../primitives/stat-bar.ts'
9
8
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
9
  import {tokens} from '../tokens/index.ts'
11
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
12
-
13
- const CATEGORY_LABELS: Record<string, string> = {
14
- ore: 'Ore',
15
- crystal: 'Crystal',
16
- gas: 'Gas',
17
- regolith: 'Regolith',
18
- biomass: 'Biomass',
19
- }
10
+ import {
11
+ shortCode,
12
+ formatMass,
13
+ tierBorder,
14
+ metaRowBlock,
15
+ BADGE_Y,
16
+ HEADER_H,
17
+ ICON_Y,
18
+ META_BLOCK_GAP,
19
+ } from './_shared.ts'
20
20
 
21
21
  function categoryColor(category?: string): string {
22
22
  if (!category) return tokens.colors.text.muted
@@ -26,6 +26,7 @@ function categoryColor(category?: string): string {
26
26
 
27
27
  export interface RenderResourceOpts {
28
28
  mode?: 'values' | 'ranges'
29
+ location?: {x: number; y: number}
29
30
  }
30
31
 
31
32
  type StatRow = {
@@ -69,19 +70,25 @@ export function renderResource(
69
70
  }))
70
71
  }
71
72
 
72
- const headerH = 48
73
- const metaRowH = 28
73
+ const metaRows = [
74
+ {label: 'Mass', value: formatMass(resolved.mass)},
75
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
76
+ ]
77
+
78
+ const metaYStart = pad + HEADER_H
79
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
80
+ const statsYStart = metaYStart + metaH + META_BLOCK_GAP
74
81
  const statsH = rows.length * 26 + 8
75
- const height = headerH + metaRowH + 14 + statsH + pad
82
+ const height = statsYStart + statsH + pad
76
83
 
77
84
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
78
85
 
79
86
  const quantity = Number(BigInt(item.quantity.toString()))
80
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
87
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
81
88
 
82
89
  const icon = iconHex({
83
90
  x: pad,
84
- y: pad + 4,
91
+ y: pad + ICON_Y,
85
92
  color: categoryColor(resolved.category),
86
93
  code: shortCode(resolved.itemId),
87
94
  })
@@ -95,46 +102,11 @@ export function renderResource(
95
102
  family: tokens.typography.display,
96
103
  })
97
104
 
98
- const catLabel = resolved.category ? (CATEGORY_LABELS[resolved.category] ?? 'Item') : '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
105
  const statsSvg = rows
134
106
  .map((row, i) =>
135
107
  statBar({
136
108
  x: pad,
137
- y: sepY + 18 + i * 26,
109
+ y: statsYStart + i * 26,
138
110
  width: innerW,
139
111
  label: row.label,
140
112
  abbreviation: row.abbreviation,
@@ -145,7 +117,7 @@ export function renderResource(
145
117
  )
146
118
  .join('')
147
119
 
148
- const inner = `${chrome}${icon}${name}${badge}${catText}${catValue}${massLabel}${massValue}${sep}${statsSvg}`
120
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
149
121
 
150
122
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
151
123
  }
@@ -1,12 +1,15 @@
1
1
  import type {TextSpan} from '@shipload/sdk'
2
+ import {formatLocation, formatMassScaled} from '@shipload/sdk'
2
3
  import {panel} from '../primitives/panel.ts'
3
4
  import {iconHex} from '../primitives/icon-hex.ts'
4
5
  import {text} from '../primitives/text.ts'
5
- import {divider} from '../primitives/divider.ts'
6
6
  import {moduleSlot} from '../primitives/module-slot.ts'
7
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
8
8
  import {wrapText} from '../primitives/wrap.ts'
9
9
  import {tokens} from '../tokens/index.ts'
10
+ import {tierBorder, metaRowBlock, BADGE_Y, HEADER_H, ICON_Y} from './_shared.ts'
11
+
12
+ const HULL_MASS_LABELS = new Set(['mass', 'capacity'])
10
13
 
11
14
  export interface ShipPanelSlot {
12
15
  name?: string
@@ -18,16 +21,15 @@ export interface ShipPanelProps {
18
21
  name: string
19
22
  tier: number
20
23
  quantity?: number
24
+ location?: {x: number; y: number}
21
25
  attributes: {capability: string; attributes: {label: string; value: number}[]}[]
22
26
  slots: ShipPanelSlot[]
23
27
  }
24
28
 
25
- function formatNumber(n: number): string {
26
- return n.toLocaleString('en-US')
27
- }
28
-
29
- function tierBorder(tier: number): string {
30
- return tokens.colors.tier[tier] ?? tokens.colors.surface.panelBorder
29
+ function formatHullValue(label: string, value: number): string {
30
+ return HULL_MASS_LABELS.has(label.toLowerCase())
31
+ ? formatMassScaled(value)
32
+ : value.toLocaleString('en-US')
31
33
  }
32
34
 
33
35
  const MODULE_LABEL_PREFIX = (capability: string) => `${capability}: `
@@ -54,22 +56,28 @@ export function renderShipPanel(props: ShipPanelProps): string {
54
56
  const quantity = props.quantity ?? 0
55
57
 
56
58
  const hullGroup = props.attributes?.find((g) => g.capability.toLowerCase() === 'hull')
57
- const hullRows = hullGroup?.attributes ?? []
59
+ const hullAttrs = (hullGroup?.attributes ?? []).map((a) => ({
60
+ label: a.label,
61
+ value: formatHullValue(a.label, a.value),
62
+ }))
63
+ const metaRows = props.location
64
+ ? [{label: 'Location', value: formatLocation(props.location)}, ...hullAttrs]
65
+ : hullAttrs
58
66
 
59
- const headerH = 48
60
- const hullHeaderH = 20
61
- const hullRowH = 22
62
67
  const sectionGap = 12
68
+
69
+ const metaYStart = pad + HEADER_H
70
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
71
+
63
72
  const rowHeights = props.slots.map(rowHeightFor)
64
73
  const modulesHeight = rowHeights.reduce((a, b) => a + b, 0)
65
- const height =
66
- headerH + hullHeaderH + hullRows.length * hullRowH + sectionGap + modulesHeight + pad
74
+ const height = metaYStart + metaH + sectionGap + modulesHeight + pad
67
75
 
68
76
  const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
69
77
 
70
78
  const icon = iconHex({
71
79
  x: pad,
72
- y: pad + 4,
80
+ y: pad + ICON_Y,
73
81
  color: tokens.colors.text.accent,
74
82
  code: 'SH',
75
83
  })
@@ -83,47 +91,9 @@ export function renderShipPanel(props: ShipPanelProps): string {
83
91
  family: tokens.typography.display,
84
92
  })
85
93
 
86
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
87
-
88
- const hullHeader = text({
89
- x: pad,
90
- y: pad + headerH,
91
- value: 'HULL',
92
- size: tokens.typography.sizes.subtitle,
93
- weight: 700,
94
- color: tokens.colors.text.secondary,
95
- letterSpacing: 1,
96
- })
97
-
98
- let y = pad + headerH + 6
99
- let hullSvg = ''
100
- for (const row of hullRows) {
101
- hullSvg +=
102
- text({
103
- x: pad,
104
- y: y + 12,
105
- value: row.label,
106
- size: tokens.typography.sizes.body,
107
- color: tokens.colors.text.secondary,
108
- }) +
109
- text({
110
- x: w - pad,
111
- y: y + 12,
112
- value: formatNumber(row.value),
113
- size: tokens.typography.sizes.body,
114
- weight: 700,
115
- anchor: 'end',
116
- }) +
117
- divider({
118
- x: pad,
119
- y: y + hullRowH - 4,
120
- width: innerW,
121
- color: tokens.colors.surface.panelBorderBright,
122
- })
123
- y += hullRowH
124
- }
94
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
125
95
 
126
- y += sectionGap
96
+ let y = metaYStart + metaH + sectionGap
127
97
  let modulesSvg = ''
128
98
  for (let i = 0; i < props.slots.length; i++) {
129
99
  const slot = props.slots[i]!
@@ -139,6 +109,6 @@ export function renderShipPanel(props: ShipPanelProps): string {
139
109
  y += rowHeights[i]!
140
110
  }
141
111
 
142
- const inner = `${chrome}${icon}${name}${badge}${hullHeader}${hullSvg}${modulesSvg}`
112
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${modulesSvg}`
143
113
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
144
114
  }