@shipload/item-renderer 0.1.0
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/.github/workflows/ci.yml +14 -0
- package/.gitignore +6 -0
- package/Makefile +50 -0
- package/biome.json +18 -0
- package/bun.lock +123 -0
- package/package.json +51 -0
- package/scripts/check-bundle-size.ts +37 -0
- package/scripts/copy-fonts.ts +41 -0
- package/scripts/preview.ts +43 -0
- package/src/errors.ts +22 -0
- package/src/fonts/index.ts +36 -0
- package/src/fonts/inter-400.woff2 +0 -0
- package/src/fonts/inter-600.woff2 +0 -0
- package/src/fonts/jetbrains-500.woff2 +0 -0
- package/src/fonts/load-bun.ts +16 -0
- package/src/fonts/orbitron-700.woff2 +0 -0
- package/src/index.ts +46 -0
- package/src/links.ts +19 -0
- package/src/meta.ts +42 -0
- package/src/payload/base64url.ts +27 -0
- package/src/payload/codec.ts +26 -0
- package/src/primitives/category-icon.ts +87 -0
- package/src/primitives/compact-row.ts +38 -0
- package/src/primitives/divider.ts +20 -0
- package/src/primitives/icon-hex.ts +39 -0
- package/src/primitives/module-slot.ts +147 -0
- package/src/primitives/panel.ts +24 -0
- package/src/primitives/quantity-badge.ts +37 -0
- package/src/primitives/span-paragraph.ts +72 -0
- package/src/primitives/stat-bar.ts +85 -0
- package/src/primitives/svg.ts +25 -0
- package/src/primitives/text.ts +42 -0
- package/src/primitives/wrap.ts +24 -0
- package/src/render.ts +33 -0
- package/src/templates/_shared.ts +15 -0
- package/src/templates/component.ts +139 -0
- package/src/templates/index.ts +30 -0
- package/src/templates/item-cell.ts +96 -0
- package/src/templates/module.ts +190 -0
- package/src/templates/packed-entity.ts +30 -0
- package/src/templates/resource.ts +151 -0
- package/src/templates/ship-panel.ts +145 -0
- package/src/tokens/colors.ts +45 -0
- package/src/tokens/index.ts +7 -0
- package/src/tokens/spacing.ts +10 -0
- package/src/tokens/typography.ts +19 -0
- package/test/__image_snapshots__/.gitkeep +0 -0
- package/test/__image_snapshots__/component-hull-plates.png +0 -0
- package/test/__image_snapshots__/module-engine-t1.png +0 -0
- package/test/__image_snapshots__/module-storage-t1.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.png +0 -0
- package/test/__image_snapshots__/resource-iron.diff.png +0 -0
- package/test/__image_snapshots__/resource-iron.png +0 -0
- package/test/__snapshots__/templates-component.test.ts.snap +5 -0
- package/test/__snapshots__/templates-item-cell.test.ts.snap +9 -0
- package/test/__snapshots__/templates-module.test.ts.snap +17 -0
- package/test/__snapshots__/templates-packed-entity.test.ts.snap +5 -0
- package/test/__snapshots__/templates-resource.test.ts.snap +7 -0
- package/test/base64url.test.ts +33 -0
- package/test/codec.test.ts +43 -0
- package/test/errors.test.ts +24 -0
- package/test/fixtures/cargo-items.ts +122 -0
- package/test/fonts.test.ts +28 -0
- package/test/links-meta.test.ts +34 -0
- package/test/pixel.test.ts +66 -0
- package/test/primitives-category-icon.test.ts +79 -0
- package/test/primitives-compact-row.test.ts +44 -0
- package/test/primitives-domain.test.ts +72 -0
- package/test/primitives-layout.test.ts +56 -0
- package/test/primitives-module-slot.test.ts +88 -0
- package/test/render.test.ts +40 -0
- package/test/sanity.test.ts +6 -0
- package/test/sdk-link.test.ts +19 -0
- package/test/snapshots/.gitkeep +0 -0
- package/test/svg.test.ts +28 -0
- package/test/templates-component.test.ts +36 -0
- package/test/templates-dispatch.test.ts +35 -0
- package/test/templates-item-cell.test.ts +94 -0
- package/test/templates-module.test.ts +63 -0
- package/test/templates-packed-entity.test.ts +47 -0
- package/test/templates-resource.test.ts +71 -0
- package/test/templates-ship-panel.test.ts +87 -0
- package/test/tokens.test.ts +32 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ResolvedItem } from '@shipload/sdk'
|
|
2
|
+
import { getComponentById, getStatDefinitions, categoryColors } from '@shipload/sdk'
|
|
3
|
+
import type { CargoItem } from '../payload/codec.ts'
|
|
4
|
+
import { panel } from '../primitives/panel.ts'
|
|
5
|
+
import { iconHex } from '../primitives/icon-hex.ts'
|
|
6
|
+
import { text } from '../primitives/text.ts'
|
|
7
|
+
import { divider } from '../primitives/divider.ts'
|
|
8
|
+
import { statBar } from '../primitives/stat-bar.ts'
|
|
9
|
+
import { quantityBadge } from '../primitives/quantity-badge.ts'
|
|
10
|
+
import { tokens } from '../tokens/index.ts'
|
|
11
|
+
import { shortCode, formatMass, tierBorder } from './_shared.ts'
|
|
12
|
+
|
|
13
|
+
export interface RenderComponentOpts {
|
|
14
|
+
mode?: 'values' | 'ranges'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type StatRow = {
|
|
18
|
+
label: string
|
|
19
|
+
abbreviation: string
|
|
20
|
+
value: number | null
|
|
21
|
+
color: string
|
|
22
|
+
inverted?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderComponent(
|
|
26
|
+
item: CargoItem,
|
|
27
|
+
resolved: ResolvedItem,
|
|
28
|
+
opts?: RenderComponentOpts,
|
|
29
|
+
): string {
|
|
30
|
+
const mode = opts?.mode ?? 'values'
|
|
31
|
+
const w = tokens.spacing.panelWidth
|
|
32
|
+
const pad = tokens.spacing.panelPadding
|
|
33
|
+
const innerW = w - pad * 2
|
|
34
|
+
|
|
35
|
+
let rows: StatRow[]
|
|
36
|
+
if (mode === 'values') {
|
|
37
|
+
rows = (resolved.stats ?? []).map(s => ({
|
|
38
|
+
label: s.label,
|
|
39
|
+
abbreviation: s.abbreviation,
|
|
40
|
+
value: s.value,
|
|
41
|
+
color: s.color,
|
|
42
|
+
inverted: s.inverted,
|
|
43
|
+
}))
|
|
44
|
+
} else {
|
|
45
|
+
const comp = getComponentById(resolved.itemId)
|
|
46
|
+
rows = (comp?.stats ?? []).flatMap(stat => {
|
|
47
|
+
const defs = getStatDefinitions(stat.source)
|
|
48
|
+
const def = defs.find(d => d.key === stat.key)
|
|
49
|
+
if (!def) return []
|
|
50
|
+
return [{
|
|
51
|
+
label: def.label,
|
|
52
|
+
abbreviation: def.abbreviation,
|
|
53
|
+
value: null,
|
|
54
|
+
color: categoryColors[stat.source],
|
|
55
|
+
inverted: def.inverted,
|
|
56
|
+
}]
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headerH = 48
|
|
61
|
+
const metaRowH = 28
|
|
62
|
+
const statsH = rows.length * 26 + 8
|
|
63
|
+
const height = headerH + metaRowH + 14 + statsH + pad
|
|
64
|
+
|
|
65
|
+
const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
|
|
66
|
+
|
|
67
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
68
|
+
const badge = quantityBadge({ x: w - pad, y: pad, quantity })
|
|
69
|
+
|
|
70
|
+
const icon = iconHex({
|
|
71
|
+
x: pad,
|
|
72
|
+
y: pad + 4,
|
|
73
|
+
color: tokens.colors.accent.component,
|
|
74
|
+
code: shortCode(resolved.itemId),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const name = text({
|
|
78
|
+
x: pad + 34,
|
|
79
|
+
y: pad + 22,
|
|
80
|
+
value: resolved.name,
|
|
81
|
+
size: tokens.typography.sizes.title,
|
|
82
|
+
weight: 700,
|
|
83
|
+
family: tokens.typography.display,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const tierNum = resolved.tier.replace(/^t/i, '')
|
|
87
|
+
const subtitleText = text({
|
|
88
|
+
x: pad,
|
|
89
|
+
y: pad + headerH + 4,
|
|
90
|
+
value: 'Type',
|
|
91
|
+
size: tokens.typography.sizes.body,
|
|
92
|
+
color: tokens.colors.text.secondary,
|
|
93
|
+
})
|
|
94
|
+
const subtitleValue = text({
|
|
95
|
+
x: w - pad,
|
|
96
|
+
y: pad + headerH + 4,
|
|
97
|
+
value: `COMPONENT · T${tierNum}`,
|
|
98
|
+
size: tokens.typography.sizes.body,
|
|
99
|
+
weight: 600,
|
|
100
|
+
anchor: 'end',
|
|
101
|
+
})
|
|
102
|
+
const massLabel = text({
|
|
103
|
+
x: pad,
|
|
104
|
+
y: pad + headerH + metaRowH - 8,
|
|
105
|
+
value: 'Mass',
|
|
106
|
+
size: tokens.typography.sizes.body,
|
|
107
|
+
color: tokens.colors.text.secondary,
|
|
108
|
+
})
|
|
109
|
+
const massValue = text({
|
|
110
|
+
x: w - pad,
|
|
111
|
+
y: pad + headerH + metaRowH - 8,
|
|
112
|
+
value: formatMass(resolved.mass),
|
|
113
|
+
size: tokens.typography.sizes.body,
|
|
114
|
+
weight: 600,
|
|
115
|
+
anchor: 'end',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const sepY = pad + headerH + metaRowH + 6
|
|
119
|
+
const sep = divider({ x: pad, y: sepY, width: innerW })
|
|
120
|
+
|
|
121
|
+
const statsSvg = rows
|
|
122
|
+
.map((row, i) =>
|
|
123
|
+
statBar({
|
|
124
|
+
x: pad,
|
|
125
|
+
y: sepY + 18 + i * 26,
|
|
126
|
+
width: innerW,
|
|
127
|
+
label: row.label,
|
|
128
|
+
abbreviation: row.abbreviation,
|
|
129
|
+
value: row.value,
|
|
130
|
+
color: row.color,
|
|
131
|
+
inverted: row.inverted,
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
.join('')
|
|
135
|
+
|
|
136
|
+
const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
|
|
137
|
+
|
|
138
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ResolvedItem } from '@shipload/sdk'
|
|
2
|
+
import type { CargoItem } from '../payload/codec.ts'
|
|
3
|
+
import { RenderError } from '../errors.ts'
|
|
4
|
+
import { renderResource } from './resource.ts'
|
|
5
|
+
import { renderPackedEntity } from './packed-entity.ts'
|
|
6
|
+
import { renderComponent } from './component.ts'
|
|
7
|
+
import { renderModule } from './module.ts'
|
|
8
|
+
|
|
9
|
+
export interface RenderByTypeOpts {
|
|
10
|
+
mode?: 'values' | 'ranges'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderByType(
|
|
14
|
+
item: CargoItem,
|
|
15
|
+
resolved: ResolvedItem,
|
|
16
|
+
opts?: RenderByTypeOpts,
|
|
17
|
+
): string {
|
|
18
|
+
switch (resolved.itemType) {
|
|
19
|
+
case 'resource':
|
|
20
|
+
return renderResource(item, resolved, opts)
|
|
21
|
+
case 'entity':
|
|
22
|
+
return renderPackedEntity(item, resolved)
|
|
23
|
+
case 'component':
|
|
24
|
+
return renderComponent(item, resolved, opts)
|
|
25
|
+
case 'module':
|
|
26
|
+
return renderModule(item, resolved, opts)
|
|
27
|
+
default:
|
|
28
|
+
throw new RenderError(`unknown itemType '${String(resolved.itemType)}'`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ResolvedItem } from '@shipload/sdk'
|
|
2
|
+
import { tierColors, categoryColors, categoryIconShapes } from '@shipload/sdk'
|
|
3
|
+
import { el } from '../primitives/svg.ts'
|
|
4
|
+
import { text } from '../primitives/text.ts'
|
|
5
|
+
import { categoryIconPath } from '../primitives/category-icon.ts'
|
|
6
|
+
import { tokens } from '../tokens/index.ts'
|
|
7
|
+
|
|
8
|
+
export interface ItemCellProps {
|
|
9
|
+
resolved: ResolvedItem
|
|
10
|
+
quantity?: number
|
|
11
|
+
size?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ItemCellGroupProps extends ItemCellProps {
|
|
15
|
+
x: number
|
|
16
|
+
y: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cellInner(props: ItemCellProps): string {
|
|
20
|
+
const size = props.size ?? 48
|
|
21
|
+
const height = Math.round(size * 1.25)
|
|
22
|
+
const r = Math.max(4, Math.round(size * 0.12))
|
|
23
|
+
const cx = size / 2
|
|
24
|
+
|
|
25
|
+
const border = el('rect', {
|
|
26
|
+
x: 0.5,
|
|
27
|
+
y: 0.5,
|
|
28
|
+
width: size - 1,
|
|
29
|
+
height: height - 1,
|
|
30
|
+
rx: r,
|
|
31
|
+
ry: r,
|
|
32
|
+
fill: tokens.colors.surface.panel,
|
|
33
|
+
stroke: tierColors[props.resolved.tier],
|
|
34
|
+
'stroke-width': 1.5,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
let content = ''
|
|
38
|
+
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
|
+
})
|
|
50
|
+
} 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({ shape, cx, cy: iconCy, size: size * 0.32, color, strokeWidth: 1.5 })
|
|
55
|
+
} else if (props.resolved.icon) {
|
|
56
|
+
content = text({
|
|
57
|
+
x: cx,
|
|
58
|
+
y: size * 0.4,
|
|
59
|
+
value: props.resolved.icon,
|
|
60
|
+
size: Math.round(size * 0.44),
|
|
61
|
+
weight: 400,
|
|
62
|
+
anchor: 'middle',
|
|
63
|
+
dominantBaseline: 'central',
|
|
64
|
+
color: tokens.colors.text.primary,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const qty = props.quantity ?? 0
|
|
69
|
+
let quantityText = ''
|
|
70
|
+
if (qty > 1) {
|
|
71
|
+
const qtyFontSize = Math.max(9, Math.round(size * 0.22))
|
|
72
|
+
const qtyPad = Math.max(3, Math.round(size * 0.12))
|
|
73
|
+
quantityText = text({
|
|
74
|
+
x: size - qtyPad,
|
|
75
|
+
y: height - qtyPad,
|
|
76
|
+
value: String(qty),
|
|
77
|
+
size: qtyFontSize,
|
|
78
|
+
weight: 700,
|
|
79
|
+
anchor: 'end',
|
|
80
|
+
color: tokens.colors.text.primary,
|
|
81
|
+
family: tokens.typography.display,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return border + content + quantityText
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function itemCellGroup(props: ItemCellGroupProps): string {
|
|
89
|
+
return `<g transform="translate(${props.x}, ${props.y})">${cellInner(props)}</g>`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderItemCell(props: ItemCellProps): string {
|
|
93
|
+
const size = props.size ?? 48
|
|
94
|
+
const height = Math.round(size * 1.25)
|
|
95
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${height}" viewBox="0 0 ${size} ${height}">${cellInner(props)}</svg>`
|
|
96
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ResolvedItem } from '@shipload/sdk'
|
|
2
|
+
import { describeModuleForItem, renderDescription } from '@shipload/sdk'
|
|
3
|
+
import type { CargoItem } from '../payload/codec.ts'
|
|
4
|
+
import { panel } from '../primitives/panel.ts'
|
|
5
|
+
import { iconHex } from '../primitives/icon-hex.ts'
|
|
6
|
+
import { text } from '../primitives/text.ts'
|
|
7
|
+
import { divider } from '../primitives/divider.ts'
|
|
8
|
+
import { compactRow } from '../primitives/compact-row.ts'
|
|
9
|
+
import { quantityBadge } from '../primitives/quantity-badge.ts'
|
|
10
|
+
import { spanParagraph } from '../primitives/span-paragraph.ts'
|
|
11
|
+
import { tokens } from '../tokens/index.ts'
|
|
12
|
+
import { shortCode, formatMass, tierBorder } from './_shared.ts'
|
|
13
|
+
|
|
14
|
+
function capabilityColor(name: string): string {
|
|
15
|
+
const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
|
|
16
|
+
return tokens.colors.capability[key] ?? tokens.colors.accent.component
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RenderModuleOpts {
|
|
20
|
+
mode?: 'values' | 'ranges'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderModule(item: CargoItem, resolved: ResolvedItem, opts?: RenderModuleOpts): string {
|
|
24
|
+
const mode = opts?.mode ?? 'values'
|
|
25
|
+
const w = tokens.spacing.panelWidth
|
|
26
|
+
const pad = tokens.spacing.panelPadding
|
|
27
|
+
const innerW = w - pad * 2
|
|
28
|
+
|
|
29
|
+
const group = resolved.attributes?.[0]
|
|
30
|
+
const attrs = group?.attributes ?? []
|
|
31
|
+
const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
|
|
32
|
+
|
|
33
|
+
const capabilityName =
|
|
34
|
+
group?.capability ??
|
|
35
|
+
resolved.name.replace(/\s+T\d+$/i, '')
|
|
36
|
+
|
|
37
|
+
const headerH = 48
|
|
38
|
+
const metaRowH = 28
|
|
39
|
+
const sepY = pad + headerH + metaRowH + 6
|
|
40
|
+
|
|
41
|
+
let bodyHeight = 0
|
|
42
|
+
if (mode === 'ranges') {
|
|
43
|
+
bodyHeight = 20 + 8
|
|
44
|
+
} else if (desc && group) {
|
|
45
|
+
const plain = renderDescription(desc)
|
|
46
|
+
.map((s) => s.text)
|
|
47
|
+
.join('')
|
|
48
|
+
const lines = plain.split(/\s+/).reduce(
|
|
49
|
+
(acc, word) => {
|
|
50
|
+
const last = acc[acc.length - 1] ?? ''
|
|
51
|
+
if (last.length === 0) return [...acc.slice(0, -1), word]
|
|
52
|
+
if (last.length + 1 + word.length <= 36) return [...acc.slice(0, -1), `${last} ${word}`]
|
|
53
|
+
return [...acc, word]
|
|
54
|
+
},
|
|
55
|
+
[''],
|
|
56
|
+
)
|
|
57
|
+
const lineCount = lines.filter((l) => l.length > 0).length
|
|
58
|
+
bodyHeight = 20 + lineCount * 14 + 8
|
|
59
|
+
} else if (group && attrs.length > 0) {
|
|
60
|
+
const capHeaderH = 22
|
|
61
|
+
const attrsH = attrs.length * 18
|
|
62
|
+
bodyHeight = capHeaderH + attrsH + 8
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const height = headerH + metaRowH + 14 + bodyHeight + pad
|
|
66
|
+
|
|
67
|
+
const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
|
|
68
|
+
|
|
69
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
70
|
+
const badge = quantityBadge({ x: w - pad, y: pad, quantity })
|
|
71
|
+
|
|
72
|
+
const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
|
|
73
|
+
const icon = iconHex({
|
|
74
|
+
x: pad,
|
|
75
|
+
y: pad + 4,
|
|
76
|
+
color: iconColor,
|
|
77
|
+
code: shortCode(resolved.itemId),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const name = text({
|
|
81
|
+
x: pad + 34,
|
|
82
|
+
y: pad + 22,
|
|
83
|
+
value: resolved.name,
|
|
84
|
+
size: tokens.typography.sizes.title,
|
|
85
|
+
weight: 700,
|
|
86
|
+
family: tokens.typography.display,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const tierNum = resolved.tier.replace(/^t/i, '')
|
|
90
|
+
const subtitleLabel = text({
|
|
91
|
+
x: pad,
|
|
92
|
+
y: pad + headerH + 4,
|
|
93
|
+
value: 'Type',
|
|
94
|
+
size: tokens.typography.sizes.body,
|
|
95
|
+
color: tokens.colors.text.secondary,
|
|
96
|
+
})
|
|
97
|
+
const subtitleValue = text({
|
|
98
|
+
x: w - pad,
|
|
99
|
+
y: pad + headerH + 4,
|
|
100
|
+
value: `MODULE · T${tierNum}`,
|
|
101
|
+
size: tokens.typography.sizes.body,
|
|
102
|
+
weight: 600,
|
|
103
|
+
anchor: 'end',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const massLabel = text({
|
|
107
|
+
x: pad,
|
|
108
|
+
y: pad + headerH + metaRowH - 8,
|
|
109
|
+
value: 'Mass',
|
|
110
|
+
size: tokens.typography.sizes.body,
|
|
111
|
+
color: tokens.colors.text.secondary,
|
|
112
|
+
})
|
|
113
|
+
const massValue = text({
|
|
114
|
+
x: w - pad,
|
|
115
|
+
y: pad + headerH + metaRowH - 8,
|
|
116
|
+
value: formatMass(resolved.mass),
|
|
117
|
+
size: tokens.typography.sizes.body,
|
|
118
|
+
weight: 600,
|
|
119
|
+
anchor: 'end',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const sep = divider({ x: pad, y: sepY, width: innerW })
|
|
123
|
+
|
|
124
|
+
let capSection = ''
|
|
125
|
+
if (mode === 'ranges') {
|
|
126
|
+
const accentColor = capabilityColor(capabilityName)
|
|
127
|
+
capSection = text({
|
|
128
|
+
x: pad,
|
|
129
|
+
y: sepY + 16,
|
|
130
|
+
value: capabilityName.toUpperCase(),
|
|
131
|
+
size: tokens.typography.sizes.subtitle,
|
|
132
|
+
weight: 700,
|
|
133
|
+
family: tokens.typography.sans,
|
|
134
|
+
color: accentColor,
|
|
135
|
+
letterSpacing: 1,
|
|
136
|
+
})
|
|
137
|
+
} else if (desc && group) {
|
|
138
|
+
const accentColor = capabilityColor(group.capability)
|
|
139
|
+
const capHeader = text({
|
|
140
|
+
x: pad,
|
|
141
|
+
y: sepY + 16,
|
|
142
|
+
value: group.capability.toUpperCase(),
|
|
143
|
+
size: tokens.typography.sizes.subtitle,
|
|
144
|
+
weight: 700,
|
|
145
|
+
family: tokens.typography.sans,
|
|
146
|
+
color: accentColor,
|
|
147
|
+
letterSpacing: 1,
|
|
148
|
+
})
|
|
149
|
+
const spans = renderDescription(desc)
|
|
150
|
+
const { svg: paraSvg } = spanParagraph({
|
|
151
|
+
x: pad,
|
|
152
|
+
y: sepY + 36,
|
|
153
|
+
spans,
|
|
154
|
+
charsPerLine: 36,
|
|
155
|
+
lineHeight: 14,
|
|
156
|
+
})
|
|
157
|
+
capSection = capHeader + paraSvg
|
|
158
|
+
} else if (group && attrs.length > 0) {
|
|
159
|
+
const capY = sepY + 22
|
|
160
|
+
const capHeader = text({
|
|
161
|
+
x: pad,
|
|
162
|
+
y: capY,
|
|
163
|
+
value: group.capability.toUpperCase(),
|
|
164
|
+
size: 10,
|
|
165
|
+
weight: 700,
|
|
166
|
+
family: tokens.typography.sans,
|
|
167
|
+
color: capabilityColor(group.capability),
|
|
168
|
+
letterSpacing: 0.8,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const attrRows = attrs
|
|
172
|
+
.map((attr, i) => {
|
|
173
|
+
const displayValue = String(attr.value)
|
|
174
|
+
return compactRow({
|
|
175
|
+
x: pad,
|
|
176
|
+
y: capY + 14 + i * 18,
|
|
177
|
+
width: innerW,
|
|
178
|
+
label: attr.label,
|
|
179
|
+
value: displayValue,
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
.join('')
|
|
183
|
+
|
|
184
|
+
capSection = capHeader + attrRows
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
|
|
188
|
+
|
|
189
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
190
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ResolvedItem, ResolvedModuleSlot } from '@shipload/sdk'
|
|
2
|
+
import { describeModuleForSlot, renderDescription } from '@shipload/sdk'
|
|
3
|
+
import type { CargoItem } from '../payload/codec.ts'
|
|
4
|
+
import { renderShipPanel, type ShipPanelSlot } from './ship-panel.ts'
|
|
5
|
+
|
|
6
|
+
function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
|
|
7
|
+
if (!slot.installed || !slot.attributes || !slot.name) {
|
|
8
|
+
return { installed: false }
|
|
9
|
+
}
|
|
10
|
+
const desc = describeModuleForSlot(slot)
|
|
11
|
+
if (desc) {
|
|
12
|
+
return { name: slot.name, installed: true, description: renderDescription(desc) }
|
|
13
|
+
}
|
|
14
|
+
const shorthand = slot.attributes
|
|
15
|
+
.map((a) => `${a.value} ${a.label.toLowerCase()}`)
|
|
16
|
+
.join(' · ')
|
|
17
|
+
return { name: slot.name, installed: true, description: shorthand }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderPackedEntity(item: CargoItem, resolved: ResolvedItem): string {
|
|
21
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
22
|
+
const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
|
|
23
|
+
return renderShipPanel({
|
|
24
|
+
name: `${resolved.name} (Packed)`,
|
|
25
|
+
tier: resolved.tier,
|
|
26
|
+
quantity,
|
|
27
|
+
attributes: resolved.attributes ?? [],
|
|
28
|
+
slots,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ResolvedItem } from '@shipload/sdk'
|
|
2
|
+
import { getStatDefinitions, categoryColors } from '@shipload/sdk'
|
|
3
|
+
import type { CargoItem } from '../payload/codec.ts'
|
|
4
|
+
import { panel } from '../primitives/panel.ts'
|
|
5
|
+
import { iconHex } from '../primitives/icon-hex.ts'
|
|
6
|
+
import { text } from '../primitives/text.ts'
|
|
7
|
+
import { divider } from '../primitives/divider.ts'
|
|
8
|
+
import { statBar } from '../primitives/stat-bar.ts'
|
|
9
|
+
import { quantityBadge } from '../primitives/quantity-badge.ts'
|
|
10
|
+
import { tokens } from '../tokens/index.ts'
|
|
11
|
+
import { shortCode, formatMass, tierBorder } from './_shared.ts'
|
|
12
|
+
|
|
13
|
+
const CATEGORY_LABELS: Record<string, string> = {
|
|
14
|
+
metal: 'Metals',
|
|
15
|
+
gas: 'Gas',
|
|
16
|
+
mineral: 'Minerals',
|
|
17
|
+
organic: 'Organic',
|
|
18
|
+
precious: 'Precious',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function categoryColor(category?: string): string {
|
|
22
|
+
if (!category) return tokens.colors.text.muted
|
|
23
|
+
const key = category as keyof typeof tokens.colors.category
|
|
24
|
+
return tokens.colors.category[key] ?? tokens.colors.text.muted
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RenderResourceOpts {
|
|
28
|
+
mode?: 'values' | 'ranges'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type StatRow = {
|
|
32
|
+
label: string
|
|
33
|
+
abbreviation: string
|
|
34
|
+
value: number | null
|
|
35
|
+
color: string
|
|
36
|
+
inverted?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function renderResource(
|
|
40
|
+
item: CargoItem,
|
|
41
|
+
resolved: ResolvedItem,
|
|
42
|
+
opts?: RenderResourceOpts,
|
|
43
|
+
): string {
|
|
44
|
+
const mode = opts?.mode ?? 'values'
|
|
45
|
+
const w = tokens.spacing.panelWidth
|
|
46
|
+
const pad = tokens.spacing.panelPadding
|
|
47
|
+
const innerW = w - pad * 2
|
|
48
|
+
|
|
49
|
+
let rows: StatRow[]
|
|
50
|
+
if (mode === 'values') {
|
|
51
|
+
rows = (resolved.stats ?? []).map(s => ({
|
|
52
|
+
label: s.label,
|
|
53
|
+
abbreviation: s.abbreviation,
|
|
54
|
+
value: s.value,
|
|
55
|
+
color: s.color,
|
|
56
|
+
inverted: s.inverted,
|
|
57
|
+
}))
|
|
58
|
+
} else {
|
|
59
|
+
const defs = resolved.category ? getStatDefinitions(resolved.category) : []
|
|
60
|
+
const color = resolved.category
|
|
61
|
+
? categoryColors[resolved.category]
|
|
62
|
+
: tokens.colors.text.muted
|
|
63
|
+
rows = defs.map(d => ({
|
|
64
|
+
label: d.label,
|
|
65
|
+
abbreviation: d.abbreviation,
|
|
66
|
+
value: null,
|
|
67
|
+
color,
|
|
68
|
+
inverted: d.inverted,
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const headerH = 48
|
|
73
|
+
const metaRowH = 28
|
|
74
|
+
const statsH = rows.length * 26 + 8
|
|
75
|
+
const height = headerH + metaRowH + 14 + statsH + pad
|
|
76
|
+
|
|
77
|
+
const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
|
|
78
|
+
|
|
79
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
80
|
+
const badge = quantityBadge({ x: w - pad, y: pad, quantity })
|
|
81
|
+
|
|
82
|
+
const icon = iconHex({
|
|
83
|
+
x: pad,
|
|
84
|
+
y: pad + 4,
|
|
85
|
+
color: categoryColor(resolved.category),
|
|
86
|
+
code: shortCode(resolved.itemId),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const name = text({
|
|
90
|
+
x: pad + 34,
|
|
91
|
+
y: pad + 22,
|
|
92
|
+
value: resolved.name,
|
|
93
|
+
size: tokens.typography.sizes.title,
|
|
94
|
+
weight: 700,
|
|
95
|
+
family: tokens.typography.display,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const catLabel = CATEGORY_LABELS[(resolved.category ?? 'metal') as string] ?? '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
|
+
const statsSvg = rows
|
|
134
|
+
.map((row, i) =>
|
|
135
|
+
statBar({
|
|
136
|
+
x: pad,
|
|
137
|
+
y: sepY + 18 + i * 26,
|
|
138
|
+
width: innerW,
|
|
139
|
+
label: row.label,
|
|
140
|
+
abbreviation: row.abbreviation,
|
|
141
|
+
value: row.value,
|
|
142
|
+
color: row.color,
|
|
143
|
+
inverted: row.inverted,
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
.join('')
|
|
147
|
+
|
|
148
|
+
const inner = `${chrome}${icon}${name}${badge}${catText}${catValue}${massLabel}${massValue}${sep}${statsSvg}`
|
|
149
|
+
|
|
150
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
151
|
+
}
|