@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 +2 -2
- package/src/index.ts +22 -8
- package/src/links.ts +11 -8
- package/src/meta.ts +1 -1
- package/src/payload/codec.ts +24 -2
- package/src/primitives/module-slot.ts +4 -4
- package/src/primitives/quantity-badge.ts +15 -5
- package/src/primitives/resource-icon.ts +242 -0
- package/src/primitives/span-paragraph.ts +1 -1
- package/src/primitives/stat-bar.ts +17 -1
- package/src/primitives/stat-stars.ts +36 -0
- package/src/primitives/text.ts +27 -1
- package/src/render.ts +12 -7
- package/src/templates/_shared.ts +113 -2
- package/src/templates/component.ts +41 -61
- package/src/templates/index.ts +2 -1
- package/src/templates/item-cell.ts +86 -38
- package/src/templates/module.ts +59 -145
- package/src/templates/packed-entity.ts +25 -5
- package/src/templates/resource.ts +40 -68
- package/src/templates/ship-panel.ts +80 -85
- package/src/tokens/colors.ts +4 -10
- package/test/fixtures/cargo-items.ts +3 -3
- package/src/primitives/category-icon.ts +0 -110
- package/src/primitives/compact-row.ts +0 -38
package/src/templates/module.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
-
import {describeModuleForItem,
|
|
2
|
+
import {describeModuleForItem, formatLocation, renderDescription} 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
6
|
import {text} from '../primitives/text.ts'
|
|
7
|
-
import {divider} from '../primitives/divider.ts'
|
|
8
|
-
import {compactRow} from '../primitives/compact-row.ts'
|
|
9
7
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
10
8
|
import {spanParagraph} from '../primitives/span-paragraph.ts'
|
|
11
9
|
import {tokens} from '../tokens/index.ts'
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
import {
|
|
11
|
+
shortCode,
|
|
12
|
+
formatMass,
|
|
13
|
+
tierBorder,
|
|
14
|
+
metaRowBlock,
|
|
15
|
+
titleText,
|
|
16
|
+
capabilityColor,
|
|
17
|
+
BADGE_Y,
|
|
18
|
+
HEADER_H,
|
|
19
|
+
ICON_Y,
|
|
20
|
+
META_BLOCK_GAP,
|
|
21
|
+
CAP_HEADER_H,
|
|
22
|
+
BODY_TAIL,
|
|
23
|
+
} from './_shared.ts'
|
|
18
24
|
|
|
19
25
|
export interface RenderModuleOpts {
|
|
20
26
|
mode?: 'values' | 'ranges'
|
|
27
|
+
location?: {x: number; y: number}
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export function renderModule(
|
|
@@ -31,162 +38,69 @@ export function renderModule(
|
|
|
31
38
|
const innerW = w - pad * 2
|
|
32
39
|
|
|
33
40
|
const group = resolved.attributes?.[0]
|
|
34
|
-
const attrs = group?.attributes ?? []
|
|
35
41
|
const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
|
|
36
42
|
|
|
37
43
|
const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
|
|
38
44
|
|
|
39
|
-
const headerH = 48
|
|
40
|
-
const metaRowH = 28
|
|
41
|
-
const sepY = pad + headerH + metaRowH + 6
|
|
42
|
-
|
|
43
|
-
let bodyHeight = 0
|
|
44
|
-
if (mode === 'ranges') {
|
|
45
|
-
bodyHeight = 20 + 8
|
|
46
|
-
} else if (desc && group) {
|
|
47
|
-
const plain = renderDescription(desc)
|
|
48
|
-
.map((s) => s.text)
|
|
49
|
-
.join('')
|
|
50
|
-
const lines = plain.split(/\s+/).reduce(
|
|
51
|
-
(acc, word) => {
|
|
52
|
-
const last = acc[acc.length - 1] ?? ''
|
|
53
|
-
if (last.length === 0) return [...acc.slice(0, -1), word]
|
|
54
|
-
if (last.length + 1 + word.length <= 36)
|
|
55
|
-
return [...acc.slice(0, -1), `${last} ${word}`]
|
|
56
|
-
return [...acc, word]
|
|
57
|
-
},
|
|
58
|
-
['']
|
|
59
|
-
)
|
|
60
|
-
const lineCount = lines.filter((l) => l.length > 0).length
|
|
61
|
-
bodyHeight = 20 + lineCount * 14 + 8
|
|
62
|
-
} else if (group && attrs.length > 0) {
|
|
63
|
-
const capHeaderH = 22
|
|
64
|
-
const attrsH = attrs.length * 18
|
|
65
|
-
bodyHeight = capHeaderH + attrsH + 8
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const height = headerH + metaRowH + 14 + bodyHeight + pad
|
|
69
|
-
|
|
70
|
-
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
71
|
-
|
|
72
45
|
const quantity = Number(BigInt(item.quantity.toString()))
|
|
73
|
-
const
|
|
46
|
+
const metaRows = [
|
|
47
|
+
{label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
|
|
48
|
+
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
49
|
+
]
|
|
74
50
|
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
y: pad + 4,
|
|
79
|
-
color: iconColor,
|
|
80
|
-
code: shortCode(resolved.itemId),
|
|
81
|
-
})
|
|
82
|
-
|
|
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
|
-
})
|
|
51
|
+
const metaYStart = pad + HEADER_H
|
|
52
|
+
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
53
|
+
const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
|
|
91
54
|
|
|
92
|
-
const
|
|
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
|
-
})
|
|
55
|
+
const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
|
|
107
56
|
|
|
108
|
-
const
|
|
57
|
+
const capLabel = (group?.capability ?? capabilityName).toUpperCase()
|
|
58
|
+
const capHeader = text({
|
|
109
59
|
x: pad,
|
|
110
|
-
y:
|
|
111
|
-
value:
|
|
112
|
-
size: tokens.typography.sizes.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
y: pad + headerH + metaRowH - 8,
|
|
118
|
-
value: formatMass(resolved.mass),
|
|
119
|
-
size: tokens.typography.sizes.body,
|
|
120
|
-
weight: 600,
|
|
121
|
-
anchor: 'end',
|
|
60
|
+
y: bodyYStart + 16,
|
|
61
|
+
value: capLabel,
|
|
62
|
+
size: tokens.typography.sizes.subtitle,
|
|
63
|
+
weight: 700,
|
|
64
|
+
family: tokens.typography.sans,
|
|
65
|
+
color: iconColor,
|
|
66
|
+
letterSpacing: 1,
|
|
122
67
|
})
|
|
123
68
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (mode === 'ranges') {
|
|
128
|
-
const accentColor = capabilityColor(capabilityName)
|
|
129
|
-
capSection = text({
|
|
130
|
-
x: pad,
|
|
131
|
-
y: sepY + 16,
|
|
132
|
-
value: capabilityName.toUpperCase(),
|
|
133
|
-
size: tokens.typography.sizes.subtitle,
|
|
134
|
-
weight: 700,
|
|
135
|
-
family: tokens.typography.sans,
|
|
136
|
-
color: accentColor,
|
|
137
|
-
letterSpacing: 1,
|
|
138
|
-
})
|
|
139
|
-
} else if (desc && group) {
|
|
140
|
-
const accentColor = capabilityColor(group.capability)
|
|
141
|
-
const capHeader = text({
|
|
142
|
-
x: pad,
|
|
143
|
-
y: sepY + 16,
|
|
144
|
-
value: group.capability.toUpperCase(),
|
|
145
|
-
size: tokens.typography.sizes.subtitle,
|
|
146
|
-
weight: 700,
|
|
147
|
-
family: tokens.typography.sans,
|
|
148
|
-
color: accentColor,
|
|
149
|
-
letterSpacing: 1,
|
|
150
|
-
})
|
|
69
|
+
let bodyHeight: number
|
|
70
|
+
let capSection: string
|
|
71
|
+
if (mode === 'values' && desc && group) {
|
|
151
72
|
const spans = renderDescription(desc)
|
|
152
|
-
const {svg: paraSvg} = spanParagraph({
|
|
73
|
+
const {svg: paraSvg, lineCount} = spanParagraph({
|
|
153
74
|
x: pad,
|
|
154
|
-
y:
|
|
75
|
+
y: bodyYStart + 36,
|
|
155
76
|
spans,
|
|
156
77
|
charsPerLine: 36,
|
|
157
78
|
lineHeight: 14,
|
|
79
|
+
highlightColor: tokens.colors.text.primary,
|
|
158
80
|
})
|
|
81
|
+
bodyHeight = CAP_HEADER_H + lineCount * 14 + BODY_TAIL
|
|
159
82
|
capSection = capHeader + paraSvg
|
|
160
|
-
} else
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
x: pad,
|
|
164
|
-
y: capY,
|
|
165
|
-
value: group.capability.toUpperCase(),
|
|
166
|
-
size: 10,
|
|
167
|
-
weight: 700,
|
|
168
|
-
family: tokens.typography.sans,
|
|
169
|
-
color: capabilityColor(group.capability),
|
|
170
|
-
letterSpacing: 0.8,
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
const attrRows = attrs
|
|
174
|
-
.map((attr, i) => {
|
|
175
|
-
const displayValue = String(attr.value)
|
|
176
|
-
return compactRow({
|
|
177
|
-
x: pad,
|
|
178
|
-
y: capY + 14 + i * 18,
|
|
179
|
-
width: innerW,
|
|
180
|
-
label: attr.label,
|
|
181
|
-
value: displayValue,
|
|
182
|
-
})
|
|
183
|
-
})
|
|
184
|
-
.join('')
|
|
185
|
-
|
|
186
|
-
capSection = capHeader + attrRows
|
|
83
|
+
} else {
|
|
84
|
+
bodyHeight = CAP_HEADER_H + BODY_TAIL
|
|
85
|
+
capSection = capHeader
|
|
187
86
|
}
|
|
188
87
|
|
|
189
|
-
const
|
|
88
|
+
const height = bodyYStart + bodyHeight + pad
|
|
89
|
+
|
|
90
|
+
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
91
|
+
|
|
92
|
+
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: iconColor})
|
|
93
|
+
|
|
94
|
+
const icon = iconHex({
|
|
95
|
+
x: pad,
|
|
96
|
+
y: pad + ICON_Y,
|
|
97
|
+
color: iconColor,
|
|
98
|
+
code: shortCode(resolved.itemId),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
102
|
+
|
|
103
|
+
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
|
|
190
104
|
|
|
191
105
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
192
106
|
}
|
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
|
|
2
|
-
import {describeModuleForSlot, renderDescription} from '@shipload/sdk'
|
|
2
|
+
import {baseName, describeModuleForSlot, 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
|
|
|
6
|
+
function capabilityFromName(name: string): string {
|
|
7
|
+
return name.replace(/\s+T\d+\s*$/i, '').trim()
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
|
|
7
11
|
if (!slot.installed || !slot.attributes || !slot.name) {
|
|
8
12
|
return {installed: false}
|
|
9
13
|
}
|
|
14
|
+
const capability = capabilityFromName(slot.name)
|
|
10
15
|
const desc = describeModuleForSlot(slot)
|
|
11
16
|
if (desc) {
|
|
12
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
name: slot.name,
|
|
19
|
+
installed: true,
|
|
20
|
+
capability,
|
|
21
|
+
description: renderDescription(desc),
|
|
22
|
+
}
|
|
13
23
|
}
|
|
14
24
|
const shorthand = slot.attributes.map((a) => `${a.value} ${a.label.toLowerCase()}`).join(' · ')
|
|
15
|
-
return {name: slot.name, installed: true, description: shorthand}
|
|
25
|
+
return {name: slot.name, installed: true, capability, description: shorthand}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RenderPackedEntityOpts {
|
|
29
|
+
mode?: 'values' | 'ranges'
|
|
30
|
+
location?: {x: number; y: number}
|
|
16
31
|
}
|
|
17
32
|
|
|
18
|
-
export function renderPackedEntity(
|
|
33
|
+
export function renderPackedEntity(
|
|
34
|
+
item: CargoItem,
|
|
35
|
+
resolved: ResolvedItem,
|
|
36
|
+
opts?: RenderPackedEntityOpts
|
|
37
|
+
): string {
|
|
19
38
|
const quantity = Number(BigInt(item.quantity.toString()))
|
|
20
39
|
const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
|
|
21
40
|
return renderShipPanel({
|
|
22
|
-
name:
|
|
41
|
+
name: baseName(resolved),
|
|
23
42
|
tier: resolved.tier,
|
|
24
43
|
quantity,
|
|
44
|
+
location: opts?.location,
|
|
25
45
|
attributes: resolved.attributes ?? [],
|
|
26
46
|
slots,
|
|
27
47
|
})
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
-
import {getStatDefinitions, categoryColors,
|
|
2
|
+
import {getStatDefinitions, categoryColors, formatLocation, starsForStat} from '@shipload/sdk'
|
|
3
3
|
import type {CargoItem} from '../payload/codec.ts'
|
|
4
4
|
import {panel} from '../primitives/panel.ts'
|
|
5
|
-
import {
|
|
6
|
-
import {text} from '../primitives/text.ts'
|
|
7
|
-
import {divider} from '../primitives/divider.ts'
|
|
5
|
+
import {resourceIcon} from '../primitives/resource-icon.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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
import {
|
|
10
|
+
formatMass,
|
|
11
|
+
tierBorder,
|
|
12
|
+
metaRowBlock,
|
|
13
|
+
titleText,
|
|
14
|
+
BADGE_Y,
|
|
15
|
+
HEADER_H,
|
|
16
|
+
ICON_Y,
|
|
17
|
+
STAT_BLOCK_GAP,
|
|
18
|
+
STAT_ROW_H,
|
|
19
|
+
BOTTOM_PAD,
|
|
20
|
+
} from './_shared.ts'
|
|
20
21
|
|
|
21
22
|
function categoryColor(category?: string): string {
|
|
22
23
|
if (!category) return tokens.colors.text.muted
|
|
@@ -26,6 +27,7 @@ function categoryColor(category?: string): string {
|
|
|
26
27
|
|
|
27
28
|
export interface RenderResourceOpts {
|
|
28
29
|
mode?: 'values' | 'ranges'
|
|
30
|
+
location?: {x: number; y: number}
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
type StatRow = {
|
|
@@ -69,83 +71,53 @@ export function renderResource(
|
|
|
69
71
|
}))
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const height = headerH + metaRowH + 14 + statsH + pad
|
|
74
|
+
const metaRows = [
|
|
75
|
+
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
76
|
+
]
|
|
76
77
|
|
|
77
|
-
const
|
|
78
|
+
const metaYStart = pad + HEADER_H
|
|
79
|
+
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
80
|
+
const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
|
|
81
|
+
const statsBottom =
|
|
82
|
+
statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
|
|
83
|
+
const height = statsBottom + BOTTOM_PAD
|
|
78
84
|
|
|
79
|
-
const
|
|
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
|
-
})
|
|
85
|
+
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
88
86
|
|
|
89
|
-
const
|
|
90
|
-
x: pad + 34,
|
|
91
|
-
y: pad + 22,
|
|
92
|
-
value: displayName(resolved),
|
|
93
|
-
size: tokens.typography.sizes.title,
|
|
94
|
-
weight: 700,
|
|
95
|
-
family: tokens.typography.display,
|
|
96
|
-
})
|
|
87
|
+
const identity = categoryColor(resolved.category)
|
|
97
88
|
|
|
98
|
-
const
|
|
99
|
-
const
|
|
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({
|
|
89
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
90
|
+
const badge = quantityBadge({
|
|
122
91
|
x: w - pad,
|
|
123
|
-
y: pad +
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
anchor: 'end',
|
|
92
|
+
y: pad + BADGE_Y,
|
|
93
|
+
quantity,
|
|
94
|
+
label: formatMass(quantity * resolved.mass),
|
|
95
|
+
tone: identity,
|
|
128
96
|
})
|
|
129
97
|
|
|
130
|
-
const
|
|
131
|
-
|
|
98
|
+
const icon = resolved.category
|
|
99
|
+
? resourceIcon(resolved.category, {x: pad, y: pad + ICON_Y - 2, size: 28})
|
|
100
|
+
: ''
|
|
101
|
+
|
|
102
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
132
103
|
|
|
133
104
|
const statsSvg = rows
|
|
134
105
|
.map((row, i) =>
|
|
135
106
|
statBar({
|
|
136
107
|
x: pad,
|
|
137
|
-
y:
|
|
108
|
+
y: statsYStart + i * STAT_ROW_H,
|
|
138
109
|
width: innerW,
|
|
139
110
|
label: row.label,
|
|
140
111
|
abbreviation: row.abbreviation,
|
|
141
112
|
value: row.value,
|
|
142
113
|
color: row.color,
|
|
143
114
|
inverted: row.inverted,
|
|
115
|
+
stars: row.value !== null ? starsForStat(row.value) : undefined,
|
|
144
116
|
})
|
|
145
117
|
)
|
|
146
118
|
.join('')
|
|
147
119
|
|
|
148
|
-
const inner = `${chrome}${icon}${name}${badge}${
|
|
120
|
+
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
|
|
149
121
|
|
|
150
122
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
151
123
|
}
|
|
@@ -1,16 +1,30 @@
|
|
|
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
|
-
import {text} from '../primitives/text.ts'
|
|
5
|
-
import {divider} from '../primitives/divider.ts'
|
|
6
5
|
import {moduleSlot} from '../primitives/module-slot.ts'
|
|
7
6
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
8
7
|
import {wrapText} from '../primitives/wrap.ts'
|
|
9
8
|
import {tokens} from '../tokens/index.ts'
|
|
9
|
+
import {
|
|
10
|
+
tierBorder,
|
|
11
|
+
metaRowBlock,
|
|
12
|
+
titleParts,
|
|
13
|
+
capabilityColor,
|
|
14
|
+
BADGE_Y,
|
|
15
|
+
HEADER_H,
|
|
16
|
+
ICON_Y,
|
|
17
|
+
BOTTOM_PAD,
|
|
18
|
+
} from './_shared.ts'
|
|
19
|
+
|
|
20
|
+
const HULL_MASS_LABELS = new Set(['mass', 'capacity'])
|
|
21
|
+
|
|
22
|
+
const ENTITY_COLOR = tokens.colors.brand.cyan
|
|
10
23
|
|
|
11
24
|
export interface ShipPanelSlot {
|
|
12
25
|
name?: string
|
|
13
26
|
installed: boolean
|
|
27
|
+
capability?: string
|
|
14
28
|
description?: string | TextSpan[]
|
|
15
29
|
}
|
|
16
30
|
|
|
@@ -18,22 +32,20 @@ export interface ShipPanelProps {
|
|
|
18
32
|
name: string
|
|
19
33
|
tier: number
|
|
20
34
|
quantity?: number
|
|
35
|
+
location?: {x: number; y: number}
|
|
21
36
|
attributes: {capability: string; attributes: {label: string; value: number}[]}[]
|
|
22
37
|
slots: ShipPanelSlot[]
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
function
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function tierBorder(tier: number): string {
|
|
30
|
-
return tokens.colors.tier[tier] ?? tokens.colors.surface.panelBorder
|
|
40
|
+
function formatHullValue(label: string, value: number): string {
|
|
41
|
+
return HULL_MASS_LABELS.has(label.toLowerCase())
|
|
42
|
+
? formatMassScaled(value)
|
|
43
|
+
: value.toLocaleString('en-US')
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
const MODULE_LABEL_PREFIX = (capability: string) => `${capability}: `
|
|
34
47
|
|
|
35
|
-
function
|
|
36
|
-
if (!slot.installed) return 24
|
|
48
|
+
function lineCountFor(slot: ShipPanelSlot): number {
|
|
37
49
|
const desc = slot.description
|
|
38
50
|
const plain =
|
|
39
51
|
typeof desc === 'string'
|
|
@@ -41,12 +53,25 @@ function rowHeightFor(slot: ShipPanelSlot): number {
|
|
|
41
53
|
: Array.isArray(desc)
|
|
42
54
|
? desc.map((s) => s.text).join('')
|
|
43
55
|
: ''
|
|
44
|
-
if (plain.length === 0) return
|
|
45
|
-
const combined = MODULE_LABEL_PREFIX(slot.name ?? 'Module') + plain
|
|
46
|
-
|
|
56
|
+
if (plain.length === 0) return 0
|
|
57
|
+
const combined = MODULE_LABEL_PREFIX(slot.capability ?? slot.name ?? 'Module') + plain
|
|
58
|
+
return Math.max(1, wrapText({value: combined, charsPerLine: 36}).length)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rowHeightFor(slot: ShipPanelSlot): number {
|
|
62
|
+
if (!slot.installed) return 24
|
|
63
|
+
const lineCount = lineCountFor(slot)
|
|
64
|
+
if (lineCount === 0) return 24
|
|
47
65
|
return 10 + lineCount * 14
|
|
48
66
|
}
|
|
49
67
|
|
|
68
|
+
function contentBottomOffsetFor(slot: ShipPanelSlot): number {
|
|
69
|
+
if (!slot.installed) return 14
|
|
70
|
+
const lineCount = lineCountFor(slot)
|
|
71
|
+
if (lineCount === 0) return 14
|
|
72
|
+
return 9 + (lineCount - 1) * 14 + 5
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
export function renderShipPanel(props: ShipPanelProps): string {
|
|
51
76
|
const w = tokens.spacing.panelWidth
|
|
52
77
|
const pad = tokens.spacing.panelPadding
|
|
@@ -54,91 +79,61 @@ export function renderShipPanel(props: ShipPanelProps): string {
|
|
|
54
79
|
const quantity = props.quantity ?? 0
|
|
55
80
|
|
|
56
81
|
const hullGroup = props.attributes?.find((g) => g.capability.toLowerCase() === 'hull')
|
|
57
|
-
const
|
|
82
|
+
const hullAttrs = (hullGroup?.attributes ?? []).map((a) => ({
|
|
83
|
+
label: a.label,
|
|
84
|
+
value: formatHullValue(a.label, a.value),
|
|
85
|
+
}))
|
|
86
|
+
const metaRows = props.location
|
|
87
|
+
? [{label: 'Location', value: formatLocation(props.location)}, ...hullAttrs]
|
|
88
|
+
: hullAttrs
|
|
58
89
|
|
|
59
|
-
const headerH = 48
|
|
60
|
-
const hullHeaderH = 20
|
|
61
|
-
const hullRowH = 22
|
|
62
90
|
const sectionGap = 12
|
|
63
|
-
const rowHeights = props.slots.map(rowHeightFor)
|
|
64
|
-
const modulesHeight = rowHeights.reduce((a, b) => a + b, 0)
|
|
65
|
-
const height =
|
|
66
|
-
headerH + hullHeaderH + hullRows.length * hullRowH + sectionGap + modulesHeight + pad
|
|
67
91
|
|
|
68
|
-
const
|
|
92
|
+
const metaYStart = pad + HEADER_H
|
|
93
|
+
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
69
94
|
|
|
70
|
-
const
|
|
71
|
-
x: pad,
|
|
72
|
-
y: pad + 4,
|
|
73
|
-
color: tokens.colors.text.accent,
|
|
74
|
-
code: 'SH',
|
|
75
|
-
})
|
|
95
|
+
const slotsYStart = metaYStart + metaH + sectionGap
|
|
76
96
|
|
|
77
|
-
|
|
78
|
-
x: pad + 34,
|
|
79
|
-
y: pad + 22,
|
|
80
|
-
value: props.name,
|
|
81
|
-
size: tokens.typography.sizes.title,
|
|
82
|
-
weight: 700,
|
|
83
|
-
family: tokens.typography.display,
|
|
84
|
-
})
|
|
85
|
-
|
|
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
|
-
}
|
|
125
|
-
|
|
126
|
-
y += sectionGap
|
|
97
|
+
let y = slotsYStart
|
|
127
98
|
let modulesSvg = ''
|
|
128
|
-
|
|
129
|
-
|
|
99
|
+
let lastContentBottom = slotsYStart
|
|
100
|
+
props.slots.forEach((slot, i) => {
|
|
101
|
+
const accentColor = slot.installed
|
|
102
|
+
? capabilityColor(slot.capability ?? slot.name ?? 'Module')
|
|
103
|
+
: ENTITY_COLOR
|
|
130
104
|
modulesSvg += moduleSlot({
|
|
131
105
|
x: pad,
|
|
132
106
|
y,
|
|
133
107
|
width: innerW,
|
|
134
108
|
installed: slot.installed,
|
|
135
|
-
capability: slot.name,
|
|
109
|
+
capability: slot.capability ?? slot.name,
|
|
136
110
|
description: slot.description,
|
|
137
|
-
accentColor
|
|
111
|
+
accentColor,
|
|
138
112
|
})
|
|
139
|
-
y
|
|
140
|
-
|
|
113
|
+
lastContentBottom = y + contentBottomOffsetFor(slot)
|
|
114
|
+
if (i < props.slots.length - 1) y += rowHeightFor(slot)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const height = lastContentBottom + BOTTOM_PAD
|
|
118
|
+
|
|
119
|
+
const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
|
|
120
|
+
|
|
121
|
+
const icon = iconHex({
|
|
122
|
+
x: pad,
|
|
123
|
+
y: pad + ICON_Y,
|
|
124
|
+
color: ENTITY_COLOR,
|
|
125
|
+
code: 'SH',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const name = titleParts(pad + 34, pad + 22, props.name, props.tier)
|
|
129
|
+
|
|
130
|
+
const badge = quantityBadge({
|
|
131
|
+
x: w - pad,
|
|
132
|
+
y: pad + BADGE_Y,
|
|
133
|
+
quantity,
|
|
134
|
+
tone: ENTITY_COLOR,
|
|
135
|
+
})
|
|
141
136
|
|
|
142
|
-
const inner = `${chrome}${icon}${name}${badge}${
|
|
137
|
+
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${modulesSvg}`
|
|
143
138
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
144
139
|
}
|