@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.
@@ -1,7 +1,11 @@
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {baseName, formatMassScaled} from '@shipload/sdk'
3
+ import {text} from '../primitives/text.ts'
4
+ import {divider} from '../primitives/divider.ts'
1
5
  import {tokens} from '../tokens/index.ts'
2
6
 
3
- export function formatMass(n: number): string {
4
- return n.toLocaleString('en-US')
7
+ export function formatMass(kg: number): string {
8
+ return formatMassScaled(kg)
5
9
  }
6
10
 
7
11
  export function tierBorder(tier: number): string {
@@ -12,3 +16,110 @@ export function shortCode(itemId: number): string {
12
16
  const str = itemId.toString(10)
13
17
  return str.slice(-2).padStart(2, '0')
14
18
  }
19
+
20
+ export function capabilityColor(name: string): string {
21
+ const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
22
+ return tokens.colors.capability[key] ?? tokens.colors.accent.component
23
+ }
24
+
25
+ export const META_ROW_H = 22
26
+ export const HEADER_H = 48
27
+ export const ICON_Y = 4
28
+ export const BADGE_Y = 6
29
+ export const META_BLOCK_GAP = 16
30
+
31
+ export const STAT_ROW_H = 26
32
+ export const CAP_HEADER_H = 22
33
+ export const CAP_ROW_H = 18
34
+ export const BODY_TAIL = 8
35
+
36
+ // Gap from the meta block to the first stat row. Resources/components have no
37
+ // body sub-header, and statBar draws its label 6px above its y, so this is
38
+ // META_BLOCK_GAP plus the offset that puts the first stat label level with
39
+ // where module/entity body sections begin — keeping the meta→body gap uniform.
40
+ export const STAT_BLOCK_GAP = META_BLOCK_GAP + 22
41
+
42
+ // Uniform gap between a card's last body element and the bottom frame edge.
43
+ // Cards size their height to (last element bottom) + BOTTOM_PAD so trailing
44
+ // space is consistent across resource / component / module / entity types.
45
+ export const BOTTOM_PAD = 22
46
+
47
+ export function titleParts(x: number, y: number, name: string, tier: number): string {
48
+ return text({
49
+ x,
50
+ y,
51
+ value: name,
52
+ size: tokens.typography.sizes.title,
53
+ weight: 700,
54
+ family: tokens.typography.display,
55
+ spans: [
56
+ {
57
+ value: `T${tier}`,
58
+ dx: 6,
59
+ size: tokens.typography.sizes.subtitle,
60
+ weight: 700,
61
+ color: tokens.colors.text.secondary,
62
+ },
63
+ ],
64
+ })
65
+ }
66
+
67
+ export function titleText(x: number, y: number, resolved: ResolvedItem): string {
68
+ // Prominent base name; tier rendered as a smaller, muted inline suffix.
69
+ return titleParts(x, y, baseName(resolved), resolved.tier)
70
+ }
71
+
72
+ export interface MetaRowProps {
73
+ x: number
74
+ y: number
75
+ width: number
76
+ label: string
77
+ value: string
78
+ showDivider?: boolean
79
+ }
80
+
81
+ export function metaRow({x, y, width, label, value, showDivider = true}: MetaRowProps): string {
82
+ const labelText = text({
83
+ x,
84
+ y: y + 12,
85
+ value: label,
86
+ size: tokens.typography.sizes.body,
87
+ color: tokens.colors.text.secondary,
88
+ })
89
+ const valueText = text({
90
+ x: x + width,
91
+ y: y + 12,
92
+ value,
93
+ size: tokens.typography.sizes.body,
94
+ weight: 700,
95
+ anchor: 'end',
96
+ })
97
+ const sep = showDivider
98
+ ? divider({
99
+ x,
100
+ y: y + META_ROW_H - 4,
101
+ width,
102
+ color: tokens.colors.surface.panelBorderBright,
103
+ })
104
+ : ''
105
+ return labelText + valueText + sep
106
+ }
107
+
108
+ export function metaRowBlock(
109
+ x: number,
110
+ yStart: number,
111
+ width: number,
112
+ rows: {label: string; value: string}[]
113
+ ): {svg: string; height: number} {
114
+ let svg = ''
115
+ rows.forEach((row, i) => {
116
+ svg += metaRow({
117
+ x,
118
+ y: yStart + i * META_ROW_H,
119
+ width,
120
+ ...row,
121
+ showDivider: i < rows.length - 1,
122
+ })
123
+ })
124
+ return {svg, height: rows.length * META_ROW_H}
125
+ }
@@ -1,17 +1,28 @@
1
- import type {ResolvedItem, ResourceCategory} from '@shipload/sdk'
2
- import {formatTier, getRecipe, getStatDefinitions, categoryColors} from '@shipload/sdk'
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {getRecipe, getStatDefinitions, resolveItemCategory, formatLocation} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {panel} from '../primitives/panel.ts'
5
5
  import {iconHex} from '../primitives/icon-hex.ts'
6
- import {text} from '../primitives/text.ts'
7
- import {divider} from '../primitives/divider.ts'
8
6
  import {statBar} from '../primitives/stat-bar.ts'
9
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
8
  import {tokens} from '../tokens/index.ts'
11
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
9
+ import {
10
+ shortCode,
11
+ formatMass,
12
+ tierBorder,
13
+ metaRowBlock,
14
+ titleText,
15
+ BADGE_Y,
16
+ HEADER_H,
17
+ ICON_Y,
18
+ STAT_BLOCK_GAP,
19
+ STAT_ROW_H,
20
+ BOTTOM_PAD,
21
+ } from './_shared.ts'
12
22
 
13
23
  export interface RenderComponentOpts {
14
24
  mode?: 'values' | 'ranges'
25
+ location?: {x: number; y: number}
15
26
  }
16
27
 
17
28
  type StatRow = {
@@ -32,13 +43,15 @@ export function renderComponent(
32
43
  const pad = tokens.spacing.panelPadding
33
44
  const innerW = w - pad * 2
34
45
 
46
+ const identity = tokens.colors.accent.component
47
+
35
48
  let rows: StatRow[]
36
49
  if (mode === 'values') {
37
50
  rows = (resolved.stats ?? []).map((s) => ({
38
51
  label: s.label,
39
52
  abbreviation: s.abbreviation,
40
53
  value: s.value,
41
- color: s.color,
54
+ color: identity,
42
55
  inverted: s.inverted,
43
56
  }))
44
57
  } else {
@@ -47,8 +60,9 @@ export function renderComponent(
47
60
  const src = slot.sources[0]
48
61
  if (!src) return []
49
62
  const input = recipe!.inputs[src.inputIndex]
50
- if (!input || !('category' in input)) return []
51
- const category = input.category as ResourceCategory
63
+ if (!input) return []
64
+ const category = resolveItemCategory(input.itemId)
65
+ if (!category) return []
52
66
  const def = getStatDefinitions(category)[src.statIndex]
53
67
  if (!def) return []
54
68
  return [
@@ -56,78 +70,44 @@ export function renderComponent(
56
70
  label: def.label,
57
71
  abbreviation: def.abbreviation,
58
72
  value: null,
59
- color: categoryColors[category],
73
+ color: identity,
60
74
  inverted: def.inverted,
61
75
  },
62
76
  ]
63
77
  })
64
78
  }
65
79
 
66
- const headerH = 48
67
- const metaRowH = 28
68
- const statsH = rows.length * 26 + 8
69
- const height = headerH + metaRowH + 14 + statsH + pad
80
+ const quantity = Number(BigInt(item.quantity.toString()))
81
+ const metaRows = [
82
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
83
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
84
+ ]
85
+
86
+ const metaYStart = pad + HEADER_H
87
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
88
+ const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
89
+ const statsBottom =
90
+ statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
91
+ const height = statsBottom + BOTTOM_PAD
70
92
 
71
93
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
72
94
 
73
- const quantity = Number(BigInt(item.quantity.toString()))
74
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
95
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: identity})
75
96
 
76
97
  const icon = iconHex({
77
98
  x: pad,
78
- y: pad + 4,
79
- color: tokens.colors.accent.component,
99
+ y: pad + ICON_Y,
100
+ color: identity,
80
101
  code: shortCode(resolved.itemId),
81
102
  })
82
103
 
83
- const name = text({
84
- x: pad + 34,
85
- y: pad + 22,
86
- value: resolved.name,
87
- size: tokens.typography.sizes.title,
88
- weight: 700,
89
- family: tokens.typography.display,
90
- })
91
-
92
- const subtitleText = text({
93
- x: pad,
94
- y: pad + headerH + 4,
95
- value: 'Type',
96
- size: tokens.typography.sizes.body,
97
- color: tokens.colors.text.secondary,
98
- })
99
- const subtitleValue = text({
100
- x: w - pad,
101
- y: pad + headerH + 4,
102
- value: `COMPONENT · ${formatTier(resolved.tier)}`,
103
- size: tokens.typography.sizes.body,
104
- weight: 600,
105
- anchor: 'end',
106
- })
107
- const massLabel = text({
108
- x: pad,
109
- y: pad + headerH + metaRowH - 8,
110
- value: 'Mass',
111
- size: tokens.typography.sizes.body,
112
- color: tokens.colors.text.secondary,
113
- })
114
- const massValue = text({
115
- x: w - pad,
116
- y: pad + headerH + metaRowH - 8,
117
- value: formatMass(resolved.mass),
118
- size: tokens.typography.sizes.body,
119
- weight: 600,
120
- anchor: 'end',
121
- })
122
-
123
- const sepY = pad + headerH + metaRowH + 6
124
- const sep = divider({x: pad, y: sepY, width: innerW})
104
+ const name = titleText(pad + 34, pad + 22, resolved)
125
105
 
126
106
  const statsSvg = rows
127
107
  .map((row, i) =>
128
108
  statBar({
129
109
  x: pad,
130
- y: sepY + 18 + i * 26,
110
+ y: statsYStart + i * STAT_ROW_H,
131
111
  width: innerW,
132
112
  label: row.label,
133
113
  abbreviation: row.abbreviation,
@@ -138,7 +118,7 @@ export function renderComponent(
138
118
  )
139
119
  .join('')
140
120
 
141
- const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
121
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
142
122
 
143
123
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
144
124
  }
@@ -8,6 +8,7 @@ import {renderModule} from './module.ts'
8
8
 
9
9
  export interface RenderByTypeOpts {
10
10
  mode?: 'values' | 'ranges'
11
+ location?: {x: number; y: number}
11
12
  }
12
13
 
13
14
  export function renderByType(
@@ -19,7 +20,7 @@ export function renderByType(
19
20
  case 'resource':
20
21
  return renderResource(item, resolved, opts)
21
22
  case 'entity':
22
- return renderPackedEntity(item, resolved)
23
+ return renderPackedEntity(item, resolved, opts)
23
24
  case 'component':
24
25
  return renderComponent(item, resolved, opts)
25
26
  case 'module':
@@ -1,14 +1,32 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {tierColors, categoryColors, categoryIconShapes} from '@shipload/sdk'
2
+ import {tierColors} from '@shipload/sdk'
3
3
  import {el} from '../primitives/svg.ts'
4
4
  import {text} from '../primitives/text.ts'
5
- import {categoryIconPath} from '../primitives/category-icon.ts'
5
+ import {resourceIcon} from '../primitives/resource-icon.ts'
6
6
  import {tokens} from '../tokens/index.ts'
7
7
 
8
8
  export interface ItemCellProps {
9
9
  resolved: ResolvedItem
10
10
  quantity?: number
11
11
  size?: number
12
+ quantityColor?: string
13
+ quantityPrefix?: string
14
+ }
15
+
16
+ export function abbreviateQuantity(n: number): string {
17
+ const abs = Math.abs(n)
18
+ if (abs < 1000) return String(n)
19
+ const units = [
20
+ {v: 1e9, s: 'b'},
21
+ {v: 1e6, s: 'm'},
22
+ {v: 1e3, s: 'k'},
23
+ ]
24
+ for (const u of units) {
25
+ if (abs >= u.v) {
26
+ return (n / u.v).toFixed(1).replace(/\.0$/, '') + u.s
27
+ }
28
+ }
29
+ return String(n)
12
30
  }
13
31
 
14
32
  export interface ItemCellGroupProps extends ItemCellProps {
@@ -34,37 +52,47 @@ function cellInner(props: ItemCellProps): string {
34
52
  'stroke-width': 1.5,
35
53
  })
36
54
 
55
+ const qty = props.quantity ?? 0
56
+ const showQuantity = props.quantityPrefix ? qty >= 1 : qty > 1
57
+
37
58
  let content = ''
38
59
  if (props.resolved.abbreviation) {
39
- const iconCy = size * 0.45
40
- content = text({
41
- x: cx,
42
- y: iconCy,
43
- value: props.resolved.abbreviation,
44
- size: Math.round(size * 0.28),
45
- weight: 700,
46
- anchor: 'middle',
47
- color: tokens.colors.text.primary,
48
- family: tokens.typography.display,
49
- })
60
+ content = showQuantity
61
+ ? text({
62
+ x: cx,
63
+ y: size * 0.45,
64
+ value: props.resolved.abbreviation,
65
+ size: Math.round(size * 0.28),
66
+ weight: 700,
67
+ anchor: 'middle',
68
+ color: tokens.colors.text.primary,
69
+ family: tokens.typography.display,
70
+ })
71
+ : text({
72
+ x: cx,
73
+ y: height / 2,
74
+ value: props.resolved.abbreviation,
75
+ size: Math.round(size * 0.36),
76
+ weight: 700,
77
+ anchor: 'middle',
78
+ dominantBaseline: 'central',
79
+ color: tokens.colors.text.primary,
80
+ family: tokens.typography.display,
81
+ })
50
82
  } else if (props.resolved.category) {
51
- const shape = categoryIconShapes[props.resolved.category]
52
- const color = categoryColors[props.resolved.category]
53
- const iconCy = size * 0.4
54
- content = categoryIconPath({
55
- shape,
56
- cx,
57
- cy: iconCy,
58
- size: size * 0.32,
59
- color,
60
- strokeWidth: 1.5,
83
+ const iconSize = Math.round(size * (showQuantity ? 0.66 : 0.84))
84
+ const iconY = showQuantity ? Math.round(size * 0.12) : Math.round(height / 2 - iconSize / 2)
85
+ content = resourceIcon(props.resolved.category, {
86
+ x: (size - iconSize) / 2,
87
+ y: iconY,
88
+ size: iconSize,
61
89
  })
62
90
  } else if (props.resolved.icon) {
63
91
  content = text({
64
92
  x: cx,
65
- y: size * 0.4,
93
+ y: showQuantity ? size * 0.4 : height / 2,
66
94
  value: props.resolved.icon,
67
- size: Math.round(size * 0.44),
95
+ size: Math.round(size * (showQuantity ? 0.44 : 0.56)),
68
96
  weight: 400,
69
97
  anchor: 'middle',
70
98
  dominantBaseline: 'central',
@@ -72,21 +100,41 @@ function cellInner(props: ItemCellProps): string {
72
100
  })
73
101
  }
74
102
 
75
- const qty = props.quantity ?? 0
76
103
  let quantityText = ''
77
- if (qty > 1) {
78
- const qtyFontSize = Math.max(9, Math.round(size * 0.22))
79
- const qtyPad = Math.max(3, Math.round(size * 0.12))
80
- quantityText = text({
81
- x: size - qtyPad,
82
- y: height - qtyPad,
83
- value: String(qty),
84
- size: qtyFontSize,
85
- weight: 700,
86
- anchor: 'end',
87
- color: tokens.colors.text.primary,
88
- family: tokens.typography.display,
104
+ if (showQuantity) {
105
+ const label = (props.quantityPrefix ?? '') + abbreviateQuantity(qty)
106
+ const fontSize = Math.max(8, Math.round(size * 0.2))
107
+ const charW = fontSize * 0.6
108
+ const padX = Math.max(2, Math.round(fontSize * 0.34))
109
+ const padY = Math.max(1, Math.round(fontSize * 0.18))
110
+ const margin = Math.max(2, Math.round(size * 0.06))
111
+ const plateW = Math.round(label.length * charW) + padX * 2
112
+ const plateH = fontSize + padY * 2
113
+ const plateRight = size - margin
114
+ const plateBottom = height - margin
115
+ const plate = el('rect', {
116
+ x: plateRight - plateW,
117
+ y: plateBottom - plateH,
118
+ width: plateW,
119
+ height: plateH,
120
+ rx: Math.round(plateH / 2),
121
+ ry: Math.round(plateH / 2),
122
+ fill: '#050c24',
123
+ 'fill-opacity': 0.82,
89
124
  })
125
+ quantityText =
126
+ plate +
127
+ text({
128
+ x: plateRight - padX,
129
+ y: plateBottom - plateH / 2,
130
+ value: label,
131
+ size: fontSize,
132
+ weight: 700,
133
+ anchor: 'end',
134
+ dominantBaseline: 'central',
135
+ color: props.quantityColor ?? tokens.colors.text.primary,
136
+ family: tokens.typography.mono,
137
+ })
90
138
  }
91
139
 
92
140
  return border + content + quantityText