@shipload/item-renderer 1.0.0-next.3 → 1.0.0-next.30

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.3",
3
+ "version": "1.0.0-next.30",
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": "workspace:^",
48
+ "@shipload/sdk": "^1.0.0-next.30",
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'
@@ -17,17 +27,22 @@ export {linkToItemPage, linkToItemImage, linkToItemSocial} from './links.ts'
17
27
  export {itemPageMeta, svgDimensions} from './meta.ts'
18
28
  export type {ItemPageMeta, ItemPageMetaOptions} from './meta.ts'
19
29
 
20
- // Tokens (consumed by testmap tailwind.config)
30
+ // Tokens (consumed by webapp tailwind.config)
21
31
  export {tokens} from './tokens/index.ts'
22
32
  export type {Tokens} from './tokens/index.ts'
23
33
  export type {CategoryColorKey, TierColorKey} from './tokens/colors.ts'
24
34
 
25
- // Category icon primitive
26
- export {categoryIconSvg, categoryIconPath} from './primitives/category-icon.ts'
27
- export type {CategoryIconPathOpts, CategoryIconSvgOpts} from './primitives/category-icon.ts'
35
+ // Resource icon primitive
36
+ export {
37
+ resourceIcon,
38
+ resourceIconBody,
39
+ resourceIconCategories,
40
+ resourceIconSvg,
41
+ } from './primitives/resource-icon.ts'
42
+ export type {ResourceIconInlineOpts, ResourceIconSvgOpts} from './primitives/resource-icon.ts'
28
43
 
29
44
  // Item cell templates
30
- export {renderItemCell, itemCellGroup} from './templates/item-cell.ts'
45
+ export {renderItemCell, itemCellGroup, abbreviateQuantity} from './templates/item-cell.ts'
31
46
  export type {ItemCellProps, ItemCellGroupProps} from './templates/item-cell.ts'
32
47
 
33
48
  // Social card template (1200x630 OG image)
@@ -51,4 +66,3 @@ export {
51
66
  type ResolvedModuleSlot,
52
67
  type ResolvedAttributeGroup,
53
68
  } from '@shipload/sdk'
54
- export type {CategoryIconShape} from '@shipload/sdk'
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
+ }
@@ -71,7 +71,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
71
71
  )
72
72
  }
73
73
 
74
- const accent = props.accentColor ?? tokens.colors.text.accent
74
+ const accent = props.accentColor ?? tokens.colors.accent.component
75
75
  const label = `${props.capability ?? 'Module'}: `
76
76
 
77
77
  const desc = props.description
@@ -89,7 +89,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
89
89
  value: label.trimEnd(),
90
90
  size: tokens.typography.sizes.body,
91
91
  weight: 600,
92
- color: tokens.colors.text.primary,
92
+ color: accent,
93
93
  })
94
94
  )
95
95
  }
@@ -99,9 +99,9 @@ export function moduleSlot(props: ModuleSlotProps): string {
99
99
  const combined = label + descPlain
100
100
  const lines = wrapText({value: combined, charsPerLine: 36})
101
101
 
102
- const highlightColor = tokens.colors.text.accent
102
+ const highlightColor = tokens.colors.text.primary
103
103
  const bodyColor = tokens.colors.text.secondary
104
- const labelColor = tokens.colors.text.primary
104
+ const labelColor = accent
105
105
  const size = tokens.typography.sizes.body
106
106
  const fontFamily = escapeXml(tokens.typography.sans)
107
107
  const labelEnd = label.length
@@ -6,11 +6,19 @@ export interface QuantityBadgeProps {
6
6
  x: number
7
7
  y: number
8
8
  quantity: number
9
+ label?: string
10
+ tone: string
9
11
  }
10
12
 
11
- export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
12
- if (quantity <= 1) return ''
13
- const label = `×${quantity}`
13
+ export function quantityBadge({
14
+ x,
15
+ y,
16
+ quantity,
17
+ label: labelOverride,
18
+ tone,
19
+ }: QuantityBadgeProps): string {
20
+ if (quantity <= 0) return ''
21
+ const label = labelOverride ?? `×${quantity}`
14
22
  const w = label.length * 7 + 12
15
23
  const h = tokens.spacing.quantityBadgeHeight
16
24
  return (
@@ -21,7 +29,9 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
21
29
  height: h,
22
30
  rx: h / 2,
23
31
  ry: h / 2,
24
- fill: tokens.colors.text.accent,
32
+ fill: tokens.colors.surface.panel,
33
+ stroke: tone,
34
+ 'stroke-width': 1.5,
25
35
  }) +
26
36
  text({
27
37
  x: x - w / 2,
@@ -30,7 +40,7 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
30
40
  size: tokens.typography.sizes.label,
31
41
  weight: 700,
32
42
  family: tokens.typography.mono,
33
- color: tokens.colors.surface.background,
43
+ color: tokens.colors.text.primary,
34
44
  anchor: 'middle',
35
45
  })
36
46
  )
@@ -0,0 +1,242 @@
1
+ import {CATEGORY_LABELS, categoryColors, type ResourceCategory} from '@shipload/sdk'
2
+ import {el, escapeXml} from './svg.ts'
3
+
4
+ export const resourceIconCategories = [
5
+ 'ore',
6
+ 'crystal',
7
+ 'gas',
8
+ 'regolith',
9
+ 'biomass',
10
+ ] as const satisfies readonly ResourceCategory[]
11
+
12
+ export interface ResourceIconSvgOpts {
13
+ size?: number
14
+ title?: string
15
+ className?: string
16
+ }
17
+
18
+ export interface ResourceIconInlineOpts {
19
+ x: number
20
+ y: number
21
+ size: number
22
+ }
23
+
24
+ const OUTLINE = '#06142f'
25
+ const HILITE = '#f7fbff'
26
+ const OUTER_STROKE = 4
27
+ const DETAIL_STROKE = 2.5
28
+
29
+ function oreIcon(): string {
30
+ return [
31
+ el('path', {
32
+ d: 'M13 24 L25 11 L43 13 L55 27 L48 49 L27 56 L12 42 Z',
33
+ fill: categoryColors.ore,
34
+ stroke: OUTLINE,
35
+ 'stroke-width': OUTER_STROKE,
36
+ 'stroke-linejoin': 'round',
37
+ }),
38
+ el('path', {d: 'M25 11 L31 30 L13 24 Z', fill: '#e39a5e'}),
39
+ el('path', {d: 'M31 30 L43 13 L55 27 L41 32 Z', fill: '#b85b35'}),
40
+ el('path', {d: 'M31 30 L41 32 L48 49 L27 56 Z', fill: '#8f432e'}),
41
+ el('path', {
42
+ d: 'M31 30 L25 11 M31 30 L13 24 M31 30 L41 32 M41 32 L48 49 M31 30 L27 56',
43
+ fill: 'none',
44
+ stroke: OUTLINE,
45
+ 'stroke-width': 1.5,
46
+ 'stroke-linecap': 'round',
47
+ 'stroke-linejoin': 'round',
48
+ opacity: 0.28,
49
+ }),
50
+ el('path', {
51
+ d: 'M21 23 L27 18 M42 20 L48 27',
52
+ fill: 'none',
53
+ stroke: '#ffd29c',
54
+ 'stroke-width': DETAIL_STROKE,
55
+ 'stroke-linecap': 'round',
56
+ opacity: 0.72,
57
+ }),
58
+ ].join('')
59
+ }
60
+
61
+ function crystalIcon(): string {
62
+ return [
63
+ el('path', {
64
+ d: 'M32 6 L49 24 L39 58 L23 58 L14 25 Z',
65
+ fill: categoryColors.crystal,
66
+ stroke: OUTLINE,
67
+ 'stroke-width': OUTER_STROKE,
68
+ 'stroke-linejoin': 'round',
69
+ }),
70
+ el('path', {d: 'M32 6 L32 58 L14 25 Z', fill: '#1fb9e4'}),
71
+ el('path', {d: 'M32 6 L49 24 L32 58 Z', fill: '#8df0ff'}),
72
+ el('path', {d: 'M23 58 L32 32 L39 58 Z', fill: '#2f87d7', opacity: 0.72}),
73
+ el('path', {
74
+ d: 'M32 6 L32 58 M14 25 L32 32 L49 24 M23 58 L32 32 L39 58',
75
+ fill: 'none',
76
+ stroke: OUTLINE,
77
+ 'stroke-width': 1.5,
78
+ 'stroke-linecap': 'round',
79
+ 'stroke-linejoin': 'round',
80
+ opacity: 0.25,
81
+ }),
82
+ el('path', {
83
+ d: 'M25 20 L31 13 M38 19 L43 25',
84
+ fill: 'none',
85
+ stroke: HILITE,
86
+ 'stroke-width': DETAIL_STROKE,
87
+ 'stroke-linecap': 'round',
88
+ opacity: 0.78,
89
+ }),
90
+ ].join('')
91
+ }
92
+
93
+ function gasIcon(): string {
94
+ return [
95
+ el('circle', {
96
+ cx: 32,
97
+ cy: 32,
98
+ r: 23,
99
+ fill: categoryColors.gas,
100
+ stroke: OUTLINE,
101
+ 'stroke-width': OUTER_STROKE,
102
+ }),
103
+ el('path', {
104
+ d: 'M17 33 C21 20 38 18 44 27 C51 38 37 51 25 43',
105
+ fill: 'none',
106
+ stroke: '#7a48d9',
107
+ 'stroke-width': 7.5,
108
+ 'stroke-linecap': 'round',
109
+ }),
110
+ el('path', {
111
+ d: 'M21 33 C25 25 36 25 39 31 C43 38 35 44 28 39',
112
+ fill: 'none',
113
+ stroke: '#f0d7ff',
114
+ 'stroke-width': 5,
115
+ 'stroke-linecap': 'round',
116
+ }),
117
+ el('circle', {
118
+ cx: 44,
119
+ cy: 18,
120
+ r: 5,
121
+ fill: '#f0d7ff',
122
+ stroke: OUTLINE,
123
+ 'stroke-width': DETAIL_STROKE,
124
+ }),
125
+ el('circle', {
126
+ cx: 19,
127
+ cy: 47,
128
+ r: 4,
129
+ fill: '#f0d7ff',
130
+ stroke: OUTLINE,
131
+ 'stroke-width': DETAIL_STROKE,
132
+ }),
133
+ ].join('')
134
+ }
135
+
136
+ function regolithIcon(): string {
137
+ return [
138
+ el('path', {
139
+ d: 'M16 14 H45 L54 25 V46 L43 55 H17 L9 43 V24 Z',
140
+ fill: categoryColors.regolith,
141
+ stroke: OUTLINE,
142
+ 'stroke-width': OUTER_STROKE,
143
+ 'stroke-linejoin': 'round',
144
+ }),
145
+ el('path', {d: 'M16 14 H45 L54 25 L34 28 Z', fill: '#dec59a'}),
146
+ el('path', {d: 'M9 43 L34 28 L43 55 H17 Z', fill: '#9e7c55', opacity: 0.78}),
147
+ el('path', {
148
+ d: 'M16 14 L34 28 L54 25 M34 28 L43 55 M34 28 L9 43',
149
+ fill: 'none',
150
+ stroke: OUTLINE,
151
+ 'stroke-width': 1.5,
152
+ 'stroke-linecap': 'round',
153
+ 'stroke-linejoin': 'round',
154
+ opacity: 0.25,
155
+ }),
156
+ el('circle', {cx: 25, cy: 28, r: 4, fill: '#806248', stroke: OUTLINE, 'stroke-width': 1.5}),
157
+ el('circle', {cx: 42, cy: 40, r: 5, fill: '#8b6a4d', stroke: OUTLINE, 'stroke-width': 1.5}),
158
+ el('circle', {
159
+ cx: 22,
160
+ cy: 44,
161
+ r: 3,
162
+ fill: '#e7d0a9',
163
+ stroke: OUTLINE,
164
+ 'stroke-width': 1.25,
165
+ }),
166
+ el('path', {
167
+ d: 'M35 18 L45 23',
168
+ fill: 'none',
169
+ stroke: '#f0dbb7',
170
+ 'stroke-width': DETAIL_STROKE,
171
+ 'stroke-linecap': 'round',
172
+ opacity: 0.72,
173
+ }),
174
+ ].join('')
175
+ }
176
+
177
+ function biomassIcon(): string {
178
+ return [
179
+ el('path', {
180
+ d: 'M32 7 C37 14 42 17 50 16 C50 24 54 29 57 35 C51 39 48 44 47 52 C39 50 36 55 31 59 C27 52 21 50 13 53 C14 45 10 40 7 34 C13 29 14 23 12 16 C21 18 26 14 32 7 Z',
181
+ fill: categoryColors.biomass,
182
+ stroke: OUTLINE,
183
+ 'stroke-width': OUTER_STROKE,
184
+ 'stroke-linejoin': 'round',
185
+ }),
186
+ el('circle', {cx: 32, cy: 32, r: 13, fill: '#73ad49', stroke: OUTLINE, 'stroke-width': 2}),
187
+ el('circle', {cx: 27, cy: 28, r: 5, fill: '#a8db6f', stroke: OUTLINE, 'stroke-width': 1.5}),
188
+ el('circle', {cx: 38, cy: 34, r: 5, fill: '#2f6f35', stroke: OUTLINE, 'stroke-width': 1.5}),
189
+ el('circle', {cx: 30, cy: 41, r: 4, fill: '#89c85a', stroke: OUTLINE, 'stroke-width': 1.5}),
190
+ el('circle', {
191
+ cx: 40,
192
+ cy: 24,
193
+ r: 3,
194
+ fill: '#c9ef8a',
195
+ stroke: OUTLINE,
196
+ 'stroke-width': 1.25,
197
+ }),
198
+ ].join('')
199
+ }
200
+
201
+ const iconBodies: Record<ResourceCategory, string> = {
202
+ ore: oreIcon(),
203
+ crystal: crystalIcon(),
204
+ gas: gasIcon(),
205
+ regolith: regolithIcon(),
206
+ biomass: biomassIcon(),
207
+ }
208
+
209
+ export function resourceIconBody(category: ResourceCategory): string {
210
+ return iconBodies[category]
211
+ }
212
+
213
+ export function resourceIcon(category: ResourceCategory, opts: ResourceIconInlineOpts): string {
214
+ const scale = opts.size / 64
215
+ return el(
216
+ 'g',
217
+ {transform: `translate(${opts.x} ${opts.y}) scale(${scale})`, 'data-resource': category},
218
+ resourceIconBody(category)
219
+ )
220
+ }
221
+
222
+ export function resourceIconSvg(
223
+ category: ResourceCategory,
224
+ opts: ResourceIconSvgOpts = {}
225
+ ): string {
226
+ const size = opts.size ?? 64
227
+ const title = opts.title ?? `${CATEGORY_LABELS[category]} resource icon`
228
+ const children = `<title>${escapeXml(title)}</title>${resourceIconBody(category)}`
229
+ return el(
230
+ 'svg',
231
+ {
232
+ xmlns: 'http://www.w3.org/2000/svg',
233
+ width: size,
234
+ height: size,
235
+ viewBox: '0 0 64 64',
236
+ role: 'img',
237
+ class: opts.className,
238
+ 'aria-label': title,
239
+ },
240
+ children
241
+ )
242
+ }
@@ -43,7 +43,7 @@ export function spanParagraph(props: SpanParagraphProps): {svg: string; lineCoun
43
43
  const chars = props.charsPerLine ?? 36
44
44
  const lh = props.lineHeight ?? 14
45
45
  const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
46
- const highlightColor = props.highlightColor ?? tokens.colors.text.accent
46
+ const highlightColor = props.highlightColor ?? tokens.colors.text.primary
47
47
  const size = props.fontSize ?? tokens.typography.sizes.body
48
48
 
49
49
  const plain = props.spans.map((s) => s.text).join('')
@@ -1,6 +1,7 @@
1
1
  import {el} from './svg.ts'
2
2
  import {text} from './text.ts'
3
3
  import {tokens} from '../tokens/index.ts'
4
+ import {statStars, STAR_BLOCK_WIDTH} from './stat-stars.ts'
4
5
 
5
6
  export interface StatBarProps {
6
7
  x: number
@@ -11,6 +12,7 @@ export interface StatBarProps {
11
12
  value: number | null // 0..1023, or null for ranges mode (no value text, no fill)
12
13
  color: string
13
14
  inverted?: boolean
15
+ stars?: number // 0..3 per-stat rating; only drawn in values mode
14
16
  }
15
17
 
16
18
  export function statBar({
@@ -22,6 +24,7 @@ export function statBar({
22
24
  value,
23
25
  color,
24
26
  inverted,
27
+ stars,
25
28
  }: StatBarProps): string {
26
29
  const h = tokens.spacing.statBarHeight
27
30
 
@@ -59,16 +62,29 @@ export function statBar({
59
62
  const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
63
  const filled = Math.floor(width * displayFraction)
61
64
 
65
+ const VALUE_COL_W = 34 // room for up to 4 mono digits at size 14, anchored end
66
+
67
+ // value text = primary; identity color = bar + code + chrome
62
68
  labelOut += text({
63
69
  x: x + width,
64
70
  y: y - 6,
65
71
  value: String(clamped),
66
72
  size: tokens.typography.sizes.statValue,
67
73
  weight: 700,
68
- color,
74
+ family: tokens.typography.mono,
75
+ color: tokens.colors.text.primary,
69
76
  anchor: 'end',
70
77
  })
71
78
 
79
+ // stars sit immediately left of the reserved value column
80
+ if (stars !== undefined) {
81
+ labelOut += statStars({
82
+ x: x + width - VALUE_COL_W - STAR_BLOCK_WIDTH,
83
+ y: y - 6,
84
+ n: stars,
85
+ })
86
+ }
87
+
72
88
  const bar = el('rect', {
73
89
  x,
74
90
  y,
@@ -0,0 +1,36 @@
1
+ import {el} from './svg.ts'
2
+
3
+ // 24×24 path, identical to the webapp ItemStatRow star.
4
+ const STAR_PATH =
5
+ 'M12 2.2l2.95 5.98 6.6.96-4.77 4.65 1.13 6.57L12 17.23 6.09 20.36l1.13-6.57L2.45 9.14l6.6-.96z'
6
+
7
+ export const STAR_SIZE = 9
8
+ export const STAR_GAP = 1
9
+ export const MAX_STARS = 3
10
+ export const STAR_BLOCK_WIDTH = MAX_STARS * STAR_SIZE + (MAX_STARS - 1) * STAR_GAP
11
+
12
+ const ON_COLOR = '#ffce5c'
13
+ const EMPTY_COLOR = 'rgba(255,255,255,0.16)'
14
+ const SCALE = STAR_SIZE / 24
15
+
16
+ export interface StatStarsProps {
17
+ x: number // left edge of the star block
18
+ y: number // vertical center the stars should sit on (text baseline)
19
+ n: number
20
+ }
21
+
22
+ // Draws MAX_STARS glyphs left-to-right starting at x; the first `n` are filled.
23
+ export function statStars({x, y, n}: StatStarsProps): string {
24
+ const filled = Math.max(0, Math.min(MAX_STARS, Math.floor(n)))
25
+ const top = y - STAR_SIZE + 1 // center the glyph on the text baseline
26
+ let out = ''
27
+ for (let i = 0; i < MAX_STARS; i++) {
28
+ const gx = x + i * (STAR_SIZE + STAR_GAP)
29
+ out += el('path', {
30
+ d: STAR_PATH,
31
+ transform: `translate(${gx} ${top}) scale(${SCALE})`,
32
+ fill: i < filled ? ON_COLOR : EMPTY_COLOR,
33
+ })
34
+ }
35
+ return out
36
+ }
@@ -1,6 +1,14 @@
1
1
  import {el} from './svg.ts'
2
2
  import {tokens} from '../tokens/index.ts'
3
3
 
4
+ export interface TextSpan {
5
+ value: string
6
+ size?: number
7
+ weight?: 400 | 600 | 700 | 500
8
+ color?: string
9
+ dx?: number
10
+ }
11
+
4
12
  export interface TextProps {
5
13
  x: number
6
14
  y: number
@@ -12,9 +20,14 @@ export interface TextProps {
12
20
  anchor?: 'start' | 'middle' | 'end'
13
21
  letterSpacing?: number
14
22
  dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
23
+ // Optional trailing tspans that flow inline after value (no manual width math).
24
+ spans?: TextSpan[]
15
25
  }
16
26
 
17
27
  export function text(props: TextProps): string {
28
+ const body = props.spans?.length
29
+ ? escapeValue(props.value) + props.spans.map(renderSpan).join('')
30
+ : escapeValue(props.value)
18
31
  return el(
19
32
  'text',
20
33
  {
@@ -28,7 +41,20 @@ export function text(props: TextProps): string {
28
41
  'letter-spacing': props.letterSpacing,
29
42
  'dominant-baseline': props.dominantBaseline,
30
43
  },
31
- escapeValue(props.value)
44
+ body
45
+ )
46
+ }
47
+
48
+ function renderSpan(span: TextSpan): string {
49
+ return el(
50
+ 'tspan',
51
+ {
52
+ dx: span.dx,
53
+ 'font-size': span.size,
54
+ 'font-weight': span.weight,
55
+ fill: span.color,
56
+ },
57
+ escapeValue(span.value)
32
58
  )
33
59
  }
34
60
 
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
  }