@shipload/item-renderer 1.0.0-next.19 → 1.0.0-next.20
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/primitives/module-slot.ts +4 -4
- package/src/primitives/quantity-badge.ts +14 -4
- 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/templates/_shared.ts +48 -1
- package/src/templates/component.ts +23 -32
- package/src/templates/module.ts +40 -106
- package/src/templates/packed-entity.ts +14 -4
- package/src/templates/resource.ts +21 -18
- package/src/templates/ship-panel.ts +61 -36
- package/src/tokens/colors.ts +2 -2
- package/src/primitives/compact-row.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipload/item-renderer",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.20",
|
|
4
4
|
"description": "Deterministic SVG rendering for Shipload items",
|
|
5
5
|
"homepage": "https://github.com/shipload/toolkit/tree/master/packages/item-renderer",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"fonts:copy": "bun run scripts/copy-fonts.ts"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@shipload/sdk": "^1.0.0-next.
|
|
48
|
+
"@shipload/sdk": "^1.0.0-next.20",
|
|
49
49
|
"@wharfkit/antelope": "1.2.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
@@ -71,7 +71,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
|
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const accent = props.accentColor ?? tokens.colors.
|
|
74
|
+
const accent = props.accentColor ?? tokens.colors.accent.component
|
|
75
75
|
const label = `${props.capability ?? 'Module'}: `
|
|
76
76
|
|
|
77
77
|
const desc = props.description
|
|
@@ -89,7 +89,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
|
|
|
89
89
|
value: label.trimEnd(),
|
|
90
90
|
size: tokens.typography.sizes.body,
|
|
91
91
|
weight: 600,
|
|
92
|
-
color:
|
|
92
|
+
color: accent,
|
|
93
93
|
})
|
|
94
94
|
)
|
|
95
95
|
}
|
|
@@ -99,9 +99,9 @@ export function moduleSlot(props: ModuleSlotProps): string {
|
|
|
99
99
|
const combined = label + descPlain
|
|
100
100
|
const lines = wrapText({value: combined, charsPerLine: 36})
|
|
101
101
|
|
|
102
|
-
const highlightColor = tokens.colors.text.
|
|
102
|
+
const highlightColor = tokens.colors.text.primary
|
|
103
103
|
const bodyColor = tokens.colors.text.secondary
|
|
104
|
-
const labelColor =
|
|
104
|
+
const labelColor = accent
|
|
105
105
|
const size = tokens.typography.sizes.body
|
|
106
106
|
const fontFamily = escapeXml(tokens.typography.sans)
|
|
107
107
|
const labelEnd = label.length
|
|
@@ -6,11 +6,19 @@ export interface QuantityBadgeProps {
|
|
|
6
6
|
x: number
|
|
7
7
|
y: number
|
|
8
8
|
quantity: number
|
|
9
|
+
label?: string
|
|
10
|
+
tone: string
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export function quantityBadge({
|
|
13
|
+
export function quantityBadge({
|
|
14
|
+
x,
|
|
15
|
+
y,
|
|
16
|
+
quantity,
|
|
17
|
+
label: labelOverride,
|
|
18
|
+
tone,
|
|
19
|
+
}: QuantityBadgeProps): string {
|
|
12
20
|
if (quantity <= 0) return ''
|
|
13
|
-
const label = `×${quantity}`
|
|
21
|
+
const label = labelOverride ?? `×${quantity}`
|
|
14
22
|
const w = label.length * 7 + 12
|
|
15
23
|
const h = tokens.spacing.quantityBadgeHeight
|
|
16
24
|
return (
|
|
@@ -21,7 +29,9 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
|
|
|
21
29
|
height: h,
|
|
22
30
|
rx: h / 2,
|
|
23
31
|
ry: h / 2,
|
|
24
|
-
fill: tokens.colors.
|
|
32
|
+
fill: tokens.colors.surface.panel,
|
|
33
|
+
stroke: tone,
|
|
34
|
+
'stroke-width': 1.5,
|
|
25
35
|
}) +
|
|
26
36
|
text({
|
|
27
37
|
x: x - w / 2,
|
|
@@ -30,7 +40,7 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
|
|
|
30
40
|
size: tokens.typography.sizes.label,
|
|
31
41
|
weight: 700,
|
|
32
42
|
family: tokens.typography.mono,
|
|
33
|
-
color: tokens.colors.
|
|
43
|
+
color: tokens.colors.text.primary,
|
|
34
44
|
anchor: 'middle',
|
|
35
45
|
})
|
|
36
46
|
)
|
|
@@ -43,7 +43,7 @@ export function spanParagraph(props: SpanParagraphProps): {svg: string; lineCoun
|
|
|
43
43
|
const chars = props.charsPerLine ?? 36
|
|
44
44
|
const lh = props.lineHeight ?? 14
|
|
45
45
|
const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
|
|
46
|
-
const highlightColor = props.highlightColor ?? tokens.colors.text.
|
|
46
|
+
const highlightColor = props.highlightColor ?? tokens.colors.text.primary
|
|
47
47
|
const size = props.fontSize ?? tokens.typography.sizes.body
|
|
48
48
|
|
|
49
49
|
const plain = props.spans.map((s) => s.text).join('')
|
|
@@ -59,13 +59,15 @@ export function statBar({
|
|
|
59
59
|
const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
|
|
60
60
|
const filled = Math.floor(width * displayFraction)
|
|
61
61
|
|
|
62
|
+
// value text = primary; identity color = bar + code + chrome
|
|
62
63
|
labelOut += text({
|
|
63
64
|
x: x + width,
|
|
64
65
|
y: y - 6,
|
|
65
66
|
value: String(clamped),
|
|
66
67
|
size: tokens.typography.sizes.statValue,
|
|
67
68
|
weight: 700,
|
|
68
|
-
|
|
69
|
+
family: tokens.typography.mono,
|
|
70
|
+
color: tokens.colors.text.primary,
|
|
69
71
|
anchor: 'end',
|
|
70
72
|
})
|
|
71
73
|
|
package/src/primitives/text.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import {el} from './svg.ts'
|
|
2
2
|
import {tokens} from '../tokens/index.ts'
|
|
3
3
|
|
|
4
|
+
export interface TextSpan {
|
|
5
|
+
value: string
|
|
6
|
+
size?: number
|
|
7
|
+
weight?: 400 | 600 | 700 | 500
|
|
8
|
+
color?: string
|
|
9
|
+
dx?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
export interface TextProps {
|
|
5
13
|
x: number
|
|
6
14
|
y: number
|
|
@@ -12,9 +20,14 @@ export interface TextProps {
|
|
|
12
20
|
anchor?: 'start' | 'middle' | 'end'
|
|
13
21
|
letterSpacing?: number
|
|
14
22
|
dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
|
|
23
|
+
// Optional trailing tspans that flow inline after value (no manual width math).
|
|
24
|
+
spans?: TextSpan[]
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
export function text(props: TextProps): string {
|
|
28
|
+
const body = props.spans?.length
|
|
29
|
+
? escapeValue(props.value) + props.spans.map(renderSpan).join('')
|
|
30
|
+
: escapeValue(props.value)
|
|
18
31
|
return el(
|
|
19
32
|
'text',
|
|
20
33
|
{
|
|
@@ -28,7 +41,20 @@ export function text(props: TextProps): string {
|
|
|
28
41
|
'letter-spacing': props.letterSpacing,
|
|
29
42
|
'dominant-baseline': props.dominantBaseline,
|
|
30
43
|
},
|
|
31
|
-
|
|
44
|
+
body
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderSpan(span: TextSpan): string {
|
|
49
|
+
return el(
|
|
50
|
+
'tspan',
|
|
51
|
+
{
|
|
52
|
+
dx: span.dx,
|
|
53
|
+
'font-size': span.size,
|
|
54
|
+
'font-weight': span.weight,
|
|
55
|
+
fill: span.color,
|
|
56
|
+
},
|
|
57
|
+
escapeValue(span.value)
|
|
32
58
|
)
|
|
33
59
|
}
|
|
34
60
|
|
package/src/templates/_shared.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
+
import {baseName, formatMassScaled} from '@shipload/sdk'
|
|
2
3
|
import {text} from '../primitives/text.ts'
|
|
3
4
|
import {divider} from '../primitives/divider.ts'
|
|
4
5
|
import {tokens} from '../tokens/index.ts'
|
|
@@ -16,12 +17,58 @@ export function shortCode(itemId: number): string {
|
|
|
16
17
|
return str.slice(-2).padStart(2, '0')
|
|
17
18
|
}
|
|
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
|
+
|
|
19
25
|
export const META_ROW_H = 22
|
|
20
26
|
export const HEADER_H = 48
|
|
21
27
|
export const ICON_Y = 4
|
|
22
28
|
export const BADGE_Y = 6
|
|
23
29
|
export const META_BLOCK_GAP = 16
|
|
24
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
|
+
|
|
25
72
|
export interface MetaRowProps {
|
|
26
73
|
x: number
|
|
27
74
|
y: number
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import type {ResolvedItem
|
|
2
|
-
import {
|
|
3
|
-
formatTier,
|
|
4
|
-
getRecipe,
|
|
5
|
-
getStatDefinitions,
|
|
6
|
-
categoryColors,
|
|
7
|
-
displayName,
|
|
8
|
-
formatLocation,
|
|
9
|
-
} from '@shipload/sdk'
|
|
1
|
+
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
+
import {getRecipe, getStatDefinitions, resolveItemCategory, formatLocation} from '@shipload/sdk'
|
|
10
3
|
import type {CargoItem} from '../payload/codec.ts'
|
|
11
4
|
import {panel} from '../primitives/panel.ts'
|
|
12
5
|
import {iconHex} from '../primitives/icon-hex.ts'
|
|
13
|
-
import {text} from '../primitives/text.ts'
|
|
14
6
|
import {statBar} from '../primitives/stat-bar.ts'
|
|
15
7
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
16
8
|
import {tokens} from '../tokens/index.ts'
|
|
@@ -19,10 +11,13 @@ import {
|
|
|
19
11
|
formatMass,
|
|
20
12
|
tierBorder,
|
|
21
13
|
metaRowBlock,
|
|
14
|
+
titleText,
|
|
22
15
|
BADGE_Y,
|
|
23
16
|
HEADER_H,
|
|
24
17
|
ICON_Y,
|
|
25
|
-
|
|
18
|
+
STAT_BLOCK_GAP,
|
|
19
|
+
STAT_ROW_H,
|
|
20
|
+
BOTTOM_PAD,
|
|
26
21
|
} from './_shared.ts'
|
|
27
22
|
|
|
28
23
|
export interface RenderComponentOpts {
|
|
@@ -48,13 +43,15 @@ export function renderComponent(
|
|
|
48
43
|
const pad = tokens.spacing.panelPadding
|
|
49
44
|
const innerW = w - pad * 2
|
|
50
45
|
|
|
46
|
+
const identity = tokens.colors.accent.component
|
|
47
|
+
|
|
51
48
|
let rows: StatRow[]
|
|
52
49
|
if (mode === 'values') {
|
|
53
50
|
rows = (resolved.stats ?? []).map((s) => ({
|
|
54
51
|
label: s.label,
|
|
55
52
|
abbreviation: s.abbreviation,
|
|
56
53
|
value: s.value,
|
|
57
|
-
color:
|
|
54
|
+
color: identity,
|
|
58
55
|
inverted: s.inverted,
|
|
59
56
|
}))
|
|
60
57
|
} else {
|
|
@@ -63,8 +60,9 @@ export function renderComponent(
|
|
|
63
60
|
const src = slot.sources[0]
|
|
64
61
|
if (!src) return []
|
|
65
62
|
const input = recipe!.inputs[src.inputIndex]
|
|
66
|
-
if (!input
|
|
67
|
-
const category = input.
|
|
63
|
+
if (!input) return []
|
|
64
|
+
const category = resolveItemCategory(input.itemId)
|
|
65
|
+
if (!category) return []
|
|
68
66
|
const def = getStatDefinitions(category)[src.statIndex]
|
|
69
67
|
if (!def) return []
|
|
70
68
|
return [
|
|
@@ -72,51 +70,44 @@ export function renderComponent(
|
|
|
72
70
|
label: def.label,
|
|
73
71
|
abbreviation: def.abbreviation,
|
|
74
72
|
value: null,
|
|
75
|
-
color:
|
|
73
|
+
color: identity,
|
|
76
74
|
inverted: def.inverted,
|
|
77
75
|
},
|
|
78
76
|
]
|
|
79
77
|
})
|
|
80
78
|
}
|
|
81
79
|
|
|
80
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
82
81
|
const metaRows = [
|
|
83
|
-
{label: '
|
|
84
|
-
{label: 'Mass', value: formatMass(resolved.mass)},
|
|
82
|
+
{label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
|
|
85
83
|
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
86
84
|
]
|
|
87
85
|
|
|
88
86
|
const metaYStart = pad + HEADER_H
|
|
89
87
|
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
90
|
-
const statsYStart = metaYStart + metaH +
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
|
93
92
|
|
|
94
93
|
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
95
94
|
|
|
96
|
-
const
|
|
97
|
-
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
|
|
95
|
+
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: identity})
|
|
98
96
|
|
|
99
97
|
const icon = iconHex({
|
|
100
98
|
x: pad,
|
|
101
99
|
y: pad + ICON_Y,
|
|
102
|
-
color:
|
|
100
|
+
color: identity,
|
|
103
101
|
code: shortCode(resolved.itemId),
|
|
104
102
|
})
|
|
105
103
|
|
|
106
|
-
const name =
|
|
107
|
-
x: pad + 34,
|
|
108
|
-
y: pad + 22,
|
|
109
|
-
value: displayName(resolved),
|
|
110
|
-
size: tokens.typography.sizes.title,
|
|
111
|
-
weight: 700,
|
|
112
|
-
family: tokens.typography.display,
|
|
113
|
-
})
|
|
104
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
114
105
|
|
|
115
106
|
const statsSvg = rows
|
|
116
107
|
.map((row, i) =>
|
|
117
108
|
statBar({
|
|
118
109
|
x: pad,
|
|
119
|
-
y: statsYStart + i *
|
|
110
|
+
y: statsYStart + i * STAT_ROW_H,
|
|
120
111
|
width: innerW,
|
|
121
112
|
label: row.label,
|
|
122
113
|
abbreviation: row.abbreviation,
|
package/src/templates/module.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
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 {compactRow} from '../primitives/compact-row.ts'
|
|
8
7
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
9
8
|
import {spanParagraph} from '../primitives/span-paragraph.ts'
|
|
10
9
|
import {tokens} from '../tokens/index.ts'
|
|
@@ -13,17 +12,16 @@ import {
|
|
|
13
12
|
formatMass,
|
|
14
13
|
tierBorder,
|
|
15
14
|
metaRowBlock,
|
|
15
|
+
titleText,
|
|
16
|
+
capabilityColor,
|
|
16
17
|
BADGE_Y,
|
|
17
18
|
HEADER_H,
|
|
18
19
|
ICON_Y,
|
|
19
20
|
META_BLOCK_GAP,
|
|
21
|
+
CAP_HEADER_H,
|
|
22
|
+
BODY_TAIL,
|
|
20
23
|
} from './_shared.ts'
|
|
21
24
|
|
|
22
|
-
function capabilityColor(name: string): string {
|
|
23
|
-
const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
|
|
24
|
-
return tokens.colors.capability[key] ?? tokens.colors.accent.component
|
|
25
|
-
}
|
|
26
|
-
|
|
27
25
|
export interface RenderModuleOpts {
|
|
28
26
|
mode?: 'values' | 'ranges'
|
|
29
27
|
location?: {x: number; y: number}
|
|
@@ -40,13 +38,13 @@ export function renderModule(
|
|
|
40
38
|
const innerW = w - pad * 2
|
|
41
39
|
|
|
42
40
|
const group = resolved.attributes?.[0]
|
|
43
|
-
const attrs = group?.attributes ?? []
|
|
44
41
|
const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
|
|
45
42
|
|
|
46
43
|
const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
|
|
47
44
|
|
|
45
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
48
46
|
const metaRows = [
|
|
49
|
-
{label: 'Mass', value: formatMass(resolved.mass)},
|
|
47
|
+
{label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
|
|
50
48
|
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
51
49
|
]
|
|
52
50
|
|
|
@@ -54,118 +52,54 @@ export function renderModule(
|
|
|
54
52
|
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
55
53
|
const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
|
|
56
54
|
|
|
57
|
-
let bodyHeight = 0
|
|
58
|
-
if (mode === 'ranges') {
|
|
59
|
-
bodyHeight = 20 + 8
|
|
60
|
-
} else if (desc && group) {
|
|
61
|
-
const plain = renderDescription(desc)
|
|
62
|
-
.map((s) => s.text)
|
|
63
|
-
.join('')
|
|
64
|
-
const lines = plain.split(/\s+/).reduce(
|
|
65
|
-
(acc, word) => {
|
|
66
|
-
const last = acc[acc.length - 1] ?? ''
|
|
67
|
-
if (last.length === 0) return [...acc.slice(0, -1), word]
|
|
68
|
-
if (last.length + 1 + word.length <= 36)
|
|
69
|
-
return [...acc.slice(0, -1), `${last} ${word}`]
|
|
70
|
-
return [...acc, word]
|
|
71
|
-
},
|
|
72
|
-
['']
|
|
73
|
-
)
|
|
74
|
-
const lineCount = lines.filter((l) => l.length > 0).length
|
|
75
|
-
bodyHeight = 20 + lineCount * 14 + 8
|
|
76
|
-
} else if (group && attrs.length > 0) {
|
|
77
|
-
const capHeaderH = 22
|
|
78
|
-
const attrsH = attrs.length * 18
|
|
79
|
-
bodyHeight = capHeaderH + attrsH + 8
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const height = bodyYStart + bodyHeight + pad
|
|
83
|
-
|
|
84
|
-
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
85
|
-
|
|
86
|
-
const quantity = Number(BigInt(item.quantity.toString()))
|
|
87
|
-
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
|
|
88
|
-
|
|
89
55
|
const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
|
|
90
|
-
const icon = iconHex({
|
|
91
|
-
x: pad,
|
|
92
|
-
y: pad + ICON_Y,
|
|
93
|
-
color: iconColor,
|
|
94
|
-
code: shortCode(resolved.itemId),
|
|
95
|
-
})
|
|
96
56
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
57
|
+
const capLabel = (group?.capability ?? capabilityName).toUpperCase()
|
|
58
|
+
const capHeader = text({
|
|
59
|
+
x: pad,
|
|
60
|
+
y: bodyYStart + 16,
|
|
61
|
+
value: capLabel,
|
|
62
|
+
size: tokens.typography.sizes.subtitle,
|
|
102
63
|
weight: 700,
|
|
103
|
-
family: tokens.typography.
|
|
64
|
+
family: tokens.typography.sans,
|
|
65
|
+
color: iconColor,
|
|
66
|
+
letterSpacing: 1,
|
|
104
67
|
})
|
|
105
68
|
|
|
106
|
-
let
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
capSection = text({
|
|
110
|
-
x: pad,
|
|
111
|
-
y: bodyYStart + 16,
|
|
112
|
-
value: capabilityName.toUpperCase(),
|
|
113
|
-
size: tokens.typography.sizes.subtitle,
|
|
114
|
-
weight: 700,
|
|
115
|
-
family: tokens.typography.sans,
|
|
116
|
-
color: accentColor,
|
|
117
|
-
letterSpacing: 1,
|
|
118
|
-
})
|
|
119
|
-
} else if (desc && group) {
|
|
120
|
-
const accentColor = capabilityColor(group.capability)
|
|
121
|
-
const capHeader = text({
|
|
122
|
-
x: pad,
|
|
123
|
-
y: bodyYStart + 16,
|
|
124
|
-
value: group.capability.toUpperCase(),
|
|
125
|
-
size: tokens.typography.sizes.subtitle,
|
|
126
|
-
weight: 700,
|
|
127
|
-
family: tokens.typography.sans,
|
|
128
|
-
color: accentColor,
|
|
129
|
-
letterSpacing: 1,
|
|
130
|
-
})
|
|
69
|
+
let bodyHeight: number
|
|
70
|
+
let capSection: string
|
|
71
|
+
if (mode === 'values' && desc && group) {
|
|
131
72
|
const spans = renderDescription(desc)
|
|
132
|
-
const {svg: paraSvg} = spanParagraph({
|
|
73
|
+
const {svg: paraSvg, lineCount} = spanParagraph({
|
|
133
74
|
x: pad,
|
|
134
75
|
y: bodyYStart + 36,
|
|
135
76
|
spans,
|
|
136
77
|
charsPerLine: 36,
|
|
137
78
|
lineHeight: 14,
|
|
79
|
+
highlightColor: tokens.colors.text.primary,
|
|
138
80
|
})
|
|
81
|
+
bodyHeight = CAP_HEADER_H + lineCount * 14 + BODY_TAIL
|
|
139
82
|
capSection = capHeader + paraSvg
|
|
140
|
-
} else
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
x: pad,
|
|
144
|
-
y: capY,
|
|
145
|
-
value: group.capability.toUpperCase(),
|
|
146
|
-
size: 10,
|
|
147
|
-
weight: 700,
|
|
148
|
-
family: tokens.typography.sans,
|
|
149
|
-
color: capabilityColor(group.capability),
|
|
150
|
-
letterSpacing: 0.8,
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
const attrRows = attrs
|
|
154
|
-
.map((attr, i) => {
|
|
155
|
-
const displayValue = String(attr.value)
|
|
156
|
-
return compactRow({
|
|
157
|
-
x: pad,
|
|
158
|
-
y: capY + 14 + i * 18,
|
|
159
|
-
width: innerW,
|
|
160
|
-
label: attr.label,
|
|
161
|
-
value: displayValue,
|
|
162
|
-
})
|
|
163
|
-
})
|
|
164
|
-
.join('')
|
|
165
|
-
|
|
166
|
-
capSection = capHeader + attrRows
|
|
83
|
+
} else {
|
|
84
|
+
bodyHeight = CAP_HEADER_H + BODY_TAIL
|
|
85
|
+
capSection = capHeader
|
|
167
86
|
}
|
|
168
87
|
|
|
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
|
+
|
|
169
103
|
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
|
|
170
104
|
|
|
171
105
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
|
|
2
|
-
import {
|
|
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}
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export interface RenderPackedEntityOpts {
|
|
@@ -28,7 +38,7 @@ export function renderPackedEntity(
|
|
|
28
38
|
const quantity = Number(BigInt(item.quantity.toString()))
|
|
29
39
|
const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
|
|
30
40
|
return renderShipPanel({
|
|
31
|
-
name:
|
|
41
|
+
name: baseName(resolved),
|
|
32
42
|
tier: resolved.tier,
|
|
33
43
|
quantity,
|
|
34
44
|
location: opts?.location,
|
|
@@ -1,9 +1,8 @@
|
|
|
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
6
|
import {statBar} from '../primitives/stat-bar.ts'
|
|
8
7
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
9
8
|
import {tokens} from '../tokens/index.ts'
|
|
@@ -12,10 +11,13 @@ import {
|
|
|
12
11
|
formatMass,
|
|
13
12
|
tierBorder,
|
|
14
13
|
metaRowBlock,
|
|
14
|
+
titleText,
|
|
15
15
|
BADGE_Y,
|
|
16
16
|
HEADER_H,
|
|
17
17
|
ICON_Y,
|
|
18
|
-
|
|
18
|
+
STAT_BLOCK_GAP,
|
|
19
|
+
STAT_ROW_H,
|
|
20
|
+
BOTTOM_PAD,
|
|
19
21
|
} from './_shared.ts'
|
|
20
22
|
|
|
21
23
|
function categoryColor(category?: string): string {
|
|
@@ -71,42 +73,43 @@ export function renderResource(
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const metaRows = [
|
|
74
|
-
{label: 'Mass', value: formatMass(resolved.mass)},
|
|
75
76
|
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
76
77
|
]
|
|
77
78
|
|
|
78
79
|
const metaYStart = pad + HEADER_H
|
|
79
80
|
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
80
|
-
const statsYStart = metaYStart + metaH +
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
|
83
85
|
|
|
84
86
|
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
85
87
|
|
|
88
|
+
const identity = categoryColor(resolved.category)
|
|
89
|
+
|
|
86
90
|
const quantity = Number(BigInt(item.quantity.toString()))
|
|
87
|
-
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
|
+
})
|
|
88
98
|
|
|
89
99
|
const icon = iconHex({
|
|
90
100
|
x: pad,
|
|
91
101
|
y: pad + ICON_Y,
|
|
92
|
-
color:
|
|
102
|
+
color: identity,
|
|
93
103
|
code: shortCode(resolved.itemId),
|
|
94
104
|
})
|
|
95
105
|
|
|
96
|
-
const name =
|
|
97
|
-
x: pad + 34,
|
|
98
|
-
y: pad + 22,
|
|
99
|
-
value: displayName(resolved),
|
|
100
|
-
size: tokens.typography.sizes.title,
|
|
101
|
-
weight: 700,
|
|
102
|
-
family: tokens.typography.display,
|
|
103
|
-
})
|
|
106
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
104
107
|
|
|
105
108
|
const statsSvg = rows
|
|
106
109
|
.map((row, i) =>
|
|
107
110
|
statBar({
|
|
108
111
|
x: pad,
|
|
109
|
-
y: statsYStart + i *
|
|
112
|
+
y: statsYStart + i * STAT_ROW_H,
|
|
110
113
|
width: innerW,
|
|
111
114
|
label: row.label,
|
|
112
115
|
abbreviation: row.abbreviation,
|
|
@@ -2,18 +2,29 @@ import type {TextSpan} from '@shipload/sdk'
|
|
|
2
2
|
import {formatLocation, formatMassScaled} from '@shipload/sdk'
|
|
3
3
|
import {panel} from '../primitives/panel.ts'
|
|
4
4
|
import {iconHex} from '../primitives/icon-hex.ts'
|
|
5
|
-
import {text} from '../primitives/text.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'
|
|
10
|
-
import {
|
|
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'
|
|
11
19
|
|
|
12
20
|
const HULL_MASS_LABELS = new Set(['mass', 'capacity'])
|
|
13
21
|
|
|
22
|
+
const ENTITY_COLOR = tokens.colors.brand.cyan
|
|
23
|
+
|
|
14
24
|
export interface ShipPanelSlot {
|
|
15
25
|
name?: string
|
|
16
26
|
installed: boolean
|
|
27
|
+
capability?: string
|
|
17
28
|
description?: string | TextSpan[]
|
|
18
29
|
}
|
|
19
30
|
|
|
@@ -34,8 +45,7 @@ function formatHullValue(label: string, value: number): string {
|
|
|
34
45
|
|
|
35
46
|
const MODULE_LABEL_PREFIX = (capability: string) => `${capability}: `
|
|
36
47
|
|
|
37
|
-
function
|
|
38
|
-
if (!slot.installed) return 24
|
|
48
|
+
function lineCountFor(slot: ShipPanelSlot): number {
|
|
39
49
|
const desc = slot.description
|
|
40
50
|
const plain =
|
|
41
51
|
typeof desc === 'string'
|
|
@@ -43,12 +53,25 @@ function rowHeightFor(slot: ShipPanelSlot): number {
|
|
|
43
53
|
: Array.isArray(desc)
|
|
44
54
|
? desc.map((s) => s.text).join('')
|
|
45
55
|
: ''
|
|
46
|
-
if (plain.length === 0) return
|
|
47
|
-
const combined = MODULE_LABEL_PREFIX(slot.name ?? 'Module') + plain
|
|
48
|
-
|
|
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
|
|
49
65
|
return 10 + lineCount * 14
|
|
50
66
|
}
|
|
51
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
|
+
|
|
52
75
|
export function renderShipPanel(props: ShipPanelProps): string {
|
|
53
76
|
const w = tokens.spacing.panelWidth
|
|
54
77
|
const pad = tokens.spacing.panelPadding
|
|
@@ -69,45 +92,47 @@ export function renderShipPanel(props: ShipPanelProps): string {
|
|
|
69
92
|
const metaYStart = pad + HEADER_H
|
|
70
93
|
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
71
94
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
const slotsYStart = metaYStart + metaH + sectionGap
|
|
96
|
+
|
|
97
|
+
let y = slotsYStart
|
|
98
|
+
let modulesSvg = ''
|
|
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
|
|
104
|
+
modulesSvg += moduleSlot({
|
|
105
|
+
x: pad,
|
|
106
|
+
y,
|
|
107
|
+
width: innerW,
|
|
108
|
+
installed: slot.installed,
|
|
109
|
+
capability: slot.capability ?? slot.name,
|
|
110
|
+
description: slot.description,
|
|
111
|
+
accentColor,
|
|
112
|
+
})
|
|
113
|
+
lastContentBottom = y + contentBottomOffsetFor(slot)
|
|
114
|
+
if (i < props.slots.length - 1) y += rowHeightFor(slot)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const height = lastContentBottom + BOTTOM_PAD
|
|
75
118
|
|
|
76
119
|
const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
|
|
77
120
|
|
|
78
121
|
const icon = iconHex({
|
|
79
122
|
x: pad,
|
|
80
123
|
y: pad + ICON_Y,
|
|
81
|
-
color:
|
|
124
|
+
color: ENTITY_COLOR,
|
|
82
125
|
code: 'SH',
|
|
83
126
|
})
|
|
84
127
|
|
|
85
|
-
const name =
|
|
86
|
-
x: pad + 34,
|
|
87
|
-
y: pad + 22,
|
|
88
|
-
value: props.name,
|
|
89
|
-
size: tokens.typography.sizes.title,
|
|
90
|
-
weight: 700,
|
|
91
|
-
family: tokens.typography.display,
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
|
|
128
|
+
const name = titleParts(pad + 34, pad + 22, props.name, props.tier)
|
|
95
129
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
y,
|
|
103
|
-
width: innerW,
|
|
104
|
-
installed: slot.installed,
|
|
105
|
-
capability: slot.name,
|
|
106
|
-
description: slot.description,
|
|
107
|
-
accentColor: tokens.colors.brand.teal,
|
|
108
|
-
})
|
|
109
|
-
y += rowHeights[i]!
|
|
110
|
-
}
|
|
130
|
+
const badge = quantityBadge({
|
|
131
|
+
x: w - pad,
|
|
132
|
+
y: pad + BADGE_Y,
|
|
133
|
+
quantity,
|
|
134
|
+
tone: ENTITY_COLOR,
|
|
135
|
+
})
|
|
111
136
|
|
|
112
137
|
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${modulesSvg}`
|
|
113
138
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
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',
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import {text} from './text.ts'
|
|
2
|
-
import {tokens} from '../tokens/index.ts'
|
|
3
|
-
|
|
4
|
-
export interface CompactRowProps {
|
|
5
|
-
x: number
|
|
6
|
-
y: number
|
|
7
|
-
width: number
|
|
8
|
-
label: string
|
|
9
|
-
value: string
|
|
10
|
-
labelColor?: string
|
|
11
|
-
valueColor?: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function compactRow(p: CompactRowProps): string {
|
|
15
|
-
const labelColor = p.labelColor ?? tokens.colors.text.secondary
|
|
16
|
-
const valueColor = p.valueColor ?? tokens.colors.text.primary
|
|
17
|
-
return (
|
|
18
|
-
text({
|
|
19
|
-
x: p.x,
|
|
20
|
-
y: p.y,
|
|
21
|
-
value: p.label,
|
|
22
|
-
size: 11,
|
|
23
|
-
weight: 500,
|
|
24
|
-
family: tokens.typography.sans,
|
|
25
|
-
color: labelColor,
|
|
26
|
-
}) +
|
|
27
|
-
text({
|
|
28
|
-
x: p.x + p.width,
|
|
29
|
-
y: p.y,
|
|
30
|
-
value: p.value,
|
|
31
|
-
size: 11,
|
|
32
|
-
weight: 700,
|
|
33
|
-
family: tokens.typography.sans,
|
|
34
|
-
color: valueColor,
|
|
35
|
-
anchor: 'end',
|
|
36
|
-
})
|
|
37
|
-
)
|
|
38
|
-
}
|