@shipload/item-renderer 1.0.0-next.2 → 1.0.0-next.21

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.2",
3
+ "version": "1.0.0-next.21",
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.21",
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,7 +27,7 @@ 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'
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
  )
@@ -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('')
@@ -59,13 +59,15 @@ export function statBar({
59
59
  const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
60
  const filled = Math.floor(width * displayFraction)
61
61
 
62
+ // value text = primary; identity color = bar + code + chrome
62
63
  labelOut += text({
63
64
  x: x + width,
64
65
  y: y - 6,
65
66
  value: String(clamped),
66
67
  size: tokens.typography.sizes.statValue,
67
68
  weight: 700,
68
- color,
69
+ family: tokens.typography.mono,
70
+ color: tokens.colors.text.primary,
69
71
  anchor: 'end',
70
72
  })
71
73
 
@@ -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
  }
@@ -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':