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

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.14",
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.14",
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
+ displayNameWithTier,
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: displayNameWithTier(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,28 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {describeModuleForItem, formatTier, renderDescription} from '@shipload/sdk'
2
+ import {
3
+ describeModuleForItem,
4
+ displayNameWithTier,
5
+ formatLocation,
6
+ renderDescription,
7
+ } from '@shipload/sdk'
3
8
  import type {CargoItem} from '../payload/codec.ts'
4
9
  import {panel} from '../primitives/panel.ts'
5
10
  import {iconHex} from '../primitives/icon-hex.ts'
6
11
  import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
12
  import {compactRow} from '../primitives/compact-row.ts'
9
13
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
14
  import {spanParagraph} from '../primitives/span-paragraph.ts'
11
15
  import {tokens} from '../tokens/index.ts'
12
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
16
+ import {
17
+ shortCode,
18
+ formatMass,
19
+ tierBorder,
20
+ metaRowBlock,
21
+ BADGE_Y,
22
+ HEADER_H,
23
+ ICON_Y,
24
+ META_BLOCK_GAP,
25
+ } from './_shared.ts'
13
26
 
14
27
  function capabilityColor(name: string): string {
15
28
  const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
@@ -18,6 +31,7 @@ function capabilityColor(name: string): string {
18
31
 
19
32
  export interface RenderModuleOpts {
20
33
  mode?: 'values' | 'ranges'
34
+ location?: {x: number; y: number}
21
35
  }
22
36
 
23
37
  export function renderModule(
@@ -36,9 +50,14 @@ export function renderModule(
36
50
 
37
51
  const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
38
52
 
39
- const headerH = 48
40
- const metaRowH = 28
41
- const sepY = pad + headerH + metaRowH + 6
53
+ const metaRows = [
54
+ {label: 'Mass', value: formatMass(resolved.mass)},
55
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
56
+ ]
57
+
58
+ const metaYStart = pad + HEADER_H
59
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
60
+ const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
42
61
 
43
62
  let bodyHeight = 0
44
63
  if (mode === 'ranges') {
@@ -65,17 +84,17 @@ export function renderModule(
65
84
  bodyHeight = capHeaderH + attrsH + 8
66
85
  }
67
86
 
68
- const height = headerH + metaRowH + 14 + bodyHeight + pad
87
+ const height = bodyYStart + bodyHeight + pad
69
88
 
70
89
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
71
90
 
72
91
  const quantity = Number(BigInt(item.quantity.toString()))
73
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
92
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
74
93
 
75
94
  const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
76
95
  const icon = iconHex({
77
96
  x: pad,
78
- y: pad + 4,
97
+ y: pad + ICON_Y,
79
98
  color: iconColor,
80
99
  code: shortCode(resolved.itemId),
81
100
  })
@@ -83,52 +102,18 @@ export function renderModule(
83
102
  const name = text({
84
103
  x: pad + 34,
85
104
  y: pad + 22,
86
- value: resolved.name,
105
+ value: displayNameWithTier(resolved),
87
106
  size: tokens.typography.sizes.title,
88
107
  weight: 700,
89
108
  family: tokens.typography.display,
90
109
  })
91
110
 
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
111
  let capSection = ''
127
112
  if (mode === 'ranges') {
128
113
  const accentColor = capabilityColor(capabilityName)
129
114
  capSection = text({
130
115
  x: pad,
131
- y: sepY + 16,
116
+ y: bodyYStart + 16,
132
117
  value: capabilityName.toUpperCase(),
133
118
  size: tokens.typography.sizes.subtitle,
134
119
  weight: 700,
@@ -140,7 +125,7 @@ export function renderModule(
140
125
  const accentColor = capabilityColor(group.capability)
141
126
  const capHeader = text({
142
127
  x: pad,
143
- y: sepY + 16,
128
+ y: bodyYStart + 16,
144
129
  value: group.capability.toUpperCase(),
145
130
  size: tokens.typography.sizes.subtitle,
146
131
  weight: 700,
@@ -151,14 +136,14 @@ export function renderModule(
151
136
  const spans = renderDescription(desc)
152
137
  const {svg: paraSvg} = spanParagraph({
153
138
  x: pad,
154
- y: sepY + 36,
139
+ y: bodyYStart + 36,
155
140
  spans,
156
141
  charsPerLine: 36,
157
142
  lineHeight: 14,
158
143
  })
159
144
  capSection = capHeader + paraSvg
160
145
  } else if (group && attrs.length > 0) {
161
- const capY = sepY + 22
146
+ const capY = bodyYStart + 22
162
147
  const capHeader = text({
163
148
  x: pad,
164
149
  y: capY,
@@ -186,7 +171,7 @@ export function renderModule(
186
171
  capSection = capHeader + attrRows
187
172
  }
188
173
 
189
- const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
174
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
190
175
 
191
176
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
192
177
  }
@@ -1,5 +1,5 @@
1
1
  import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
2
- import {describeModuleForSlot, renderDescription} from '@shipload/sdk'
2
+ import {describeModuleForSlot, displayNameWithTier, 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: `${displayNameWithTier(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,27 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {getStatDefinitions, categoryColors, displayName} from '@shipload/sdk'
2
+ import {
3
+ getStatDefinitions,
4
+ categoryColors,
5
+ displayNameWithTier,
6
+ formatLocation,
7
+ } from '@shipload/sdk'
3
8
  import type {CargoItem} from '../payload/codec.ts'
4
9
  import {panel} from '../primitives/panel.ts'
5
10
  import {iconHex} from '../primitives/icon-hex.ts'
6
11
  import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
12
  import {statBar} from '../primitives/stat-bar.ts'
9
13
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
14
  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
- }
15
+ import {
16
+ shortCode,
17
+ formatMass,
18
+ tierBorder,
19
+ metaRowBlock,
20
+ BADGE_Y,
21
+ HEADER_H,
22
+ ICON_Y,
23
+ META_BLOCK_GAP,
24
+ } from './_shared.ts'
20
25
 
21
26
  function categoryColor(category?: string): string {
22
27
  if (!category) return tokens.colors.text.muted
@@ -26,6 +31,7 @@ function categoryColor(category?: string): string {
26
31
 
27
32
  export interface RenderResourceOpts {
28
33
  mode?: 'values' | 'ranges'
34
+ location?: {x: number; y: number}
29
35
  }
30
36
 
31
37
  type StatRow = {
@@ -69,19 +75,25 @@ export function renderResource(
69
75
  }))
70
76
  }
71
77
 
72
- const headerH = 48
73
- const metaRowH = 28
78
+ const metaRows = [
79
+ {label: 'Mass', value: formatMass(resolved.mass)},
80
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
81
+ ]
82
+
83
+ const metaYStart = pad + HEADER_H
84
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
85
+ const statsYStart = metaYStart + metaH + META_BLOCK_GAP
74
86
  const statsH = rows.length * 26 + 8
75
- const height = headerH + metaRowH + 14 + statsH + pad
87
+ const height = statsYStart + statsH + pad
76
88
 
77
89
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
78
90
 
79
91
  const quantity = Number(BigInt(item.quantity.toString()))
80
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
92
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
81
93
 
82
94
  const icon = iconHex({
83
95
  x: pad,
84
- y: pad + 4,
96
+ y: pad + ICON_Y,
85
97
  color: categoryColor(resolved.category),
86
98
  code: shortCode(resolved.itemId),
87
99
  })
@@ -89,52 +101,17 @@ export function renderResource(
89
101
  const name = text({
90
102
  x: pad + 34,
91
103
  y: pad + 22,
92
- value: displayName(resolved),
104
+ value: displayNameWithTier(resolved),
93
105
  size: tokens.typography.sizes.title,
94
106
  weight: 700,
95
107
  family: tokens.typography.display,
96
108
  })
97
109
 
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
110
  const statsSvg = rows
134
111
  .map((row, i) =>
135
112
  statBar({
136
113
  x: pad,
137
- y: sepY + 18 + i * 26,
114
+ y: statsYStart + i * 26,
138
115
  width: innerW,
139
116
  label: row.label,
140
117
  abbreviation: row.abbreviation,
@@ -145,7 +122,7 @@ export function renderResource(
145
122
  )
146
123
  .join('')
147
124
 
148
- const inner = `${chrome}${icon}${name}${badge}${catText}${catValue}${massLabel}${massValue}${sep}${statsSvg}`
125
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
149
126
 
150
127
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
151
128
  }
@@ -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
  }