@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 +2 -2
- package/src/index.ts +13 -3
- 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/span-paragraph.ts +1 -1
- package/src/primitives/stat-bar.ts +3 -1
- 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/module.ts +59 -145
- package/src/templates/packed-entity.ts +25 -5
- package/src/templates/resource.ts +39 -64
- package/src/templates/ship-panel.ts +80 -85
- package/src/tokens/colors.ts +2 -2
- package/test/fixtures/cargo-items.ts +3 -3
- 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,24 @@
|
|
|
1
1
|
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
-
import {getStatDefinitions, categoryColors,
|
|
2
|
+
import {getStatDefinitions, categoryColors, 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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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'
|
|
20
22
|
|
|
21
23
|
function categoryColor(category?: string): string {
|
|
22
24
|
if (!category) return tokens.colors.text.muted
|
|
@@ -26,6 +28,7 @@ function categoryColor(category?: string): string {
|
|
|
26
28
|
|
|
27
29
|
export interface RenderResourceOpts {
|
|
28
30
|
mode?: 'values' | 'ranges'
|
|
31
|
+
location?: {x: number; y: number}
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
type StatRow = {
|
|
@@ -69,72 +72,44 @@ export function renderResource(
|
|
|
69
72
|
}))
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const metaRows = [
|
|
76
|
+
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
const metaYStart = pad + HEADER_H
|
|
80
|
+
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
81
|
+
const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
|
|
82
|
+
const statsBottom =
|
|
83
|
+
statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
|
|
84
|
+
const height = statsBottom + BOTTOM_PAD
|
|
76
85
|
|
|
77
86
|
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
78
87
|
|
|
88
|
+
const identity = categoryColor(resolved.category)
|
|
89
|
+
|
|
79
90
|
const quantity = Number(BigInt(item.quantity.toString()))
|
|
80
|
-
const badge = quantityBadge({
|
|
91
|
+
const badge = quantityBadge({
|
|
92
|
+
x: w - pad,
|
|
93
|
+
y: pad + BADGE_Y,
|
|
94
|
+
quantity,
|
|
95
|
+
label: formatMass(quantity * resolved.mass),
|
|
96
|
+
tone: identity,
|
|
97
|
+
})
|
|
81
98
|
|
|
82
99
|
const icon = iconHex({
|
|
83
100
|
x: pad,
|
|
84
|
-
y: pad +
|
|
85
|
-
color:
|
|
101
|
+
y: pad + ICON_Y,
|
|
102
|
+
color: identity,
|
|
86
103
|
code: shortCode(resolved.itemId),
|
|
87
104
|
})
|
|
88
105
|
|
|
89
|
-
const name =
|
|
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
|
-
})
|
|
97
|
-
|
|
98
|
-
const catLabel = resolved.category ? (CATEGORY_LABELS[resolved.category] ?? 'Item') : '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})
|
|
106
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
132
107
|
|
|
133
108
|
const statsSvg = rows
|
|
134
109
|
.map((row, i) =>
|
|
135
110
|
statBar({
|
|
136
111
|
x: pad,
|
|
137
|
-
y:
|
|
112
|
+
y: statsYStart + i * STAT_ROW_H,
|
|
138
113
|
width: innerW,
|
|
139
114
|
label: row.label,
|
|
140
115
|
abbreviation: row.abbreviation,
|
|
@@ -145,7 +120,7 @@ export function renderResource(
|
|
|
145
120
|
)
|
|
146
121
|
.join('')
|
|
147
122
|
|
|
148
|
-
const inner = `${chrome}${icon}${name}${badge}${
|
|
123
|
+
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
|
|
149
124
|
|
|
150
125
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
151
126
|
}
|
|
@@ -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
|
}
|
package/src/tokens/colors.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const colors = {
|
|
|
10
10
|
text: {
|
|
11
11
|
primary: '#e6e8ec',
|
|
12
12
|
secondary: '#8f98a8',
|
|
13
|
-
muted: '#
|
|
13
|
+
muted: '#7c8698',
|
|
14
14
|
accent: '#f4c96b',
|
|
15
15
|
},
|
|
16
16
|
brand: {
|
|
@@ -27,7 +27,7 @@ export const colors = {
|
|
|
27
27
|
},
|
|
28
28
|
tier: tierColors,
|
|
29
29
|
accent: {
|
|
30
|
-
component: '#
|
|
30
|
+
component: '#7E93C4',
|
|
31
31
|
},
|
|
32
32
|
capability: {
|
|
33
33
|
engine: '#4a8abf',
|