@shipload/item-renderer 1.0.0-next.2 → 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/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/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": "
|
|
48
|
+
"@shipload/sdk": "^1.0.0-next.20",
|
|
49
49
|
"@wharfkit/antelope": "1.2.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,18 @@ export const VERSION = '0.1.0'
|
|
|
5
5
|
export {InvalidPayloadError, UnknownItemError, RenderError} from './errors.ts'
|
|
6
6
|
|
|
7
7
|
// Payload
|
|
8
|
-
export {
|
|
9
|
-
|
|
8
|
+
export {
|
|
9
|
+
encodeCargoItem,
|
|
10
|
+
decodeCargoItem,
|
|
11
|
+
encodeNftPayload,
|
|
12
|
+
decodeNftPayload,
|
|
13
|
+
} from './payload/codec.ts'
|
|
14
|
+
export type {
|
|
15
|
+
CargoItem,
|
|
16
|
+
CargoItemLike,
|
|
17
|
+
NftItemPayload,
|
|
18
|
+
NftItemPayloadLike,
|
|
19
|
+
} from './payload/codec.ts'
|
|
10
20
|
|
|
11
21
|
// Rendering
|
|
12
22
|
export {renderItem, renderFromPayload, type RenderOptions} from './render.ts'
|
|
@@ -17,7 +27,7 @@ export {linkToItemPage, linkToItemImage, linkToItemSocial} from './links.ts'
|
|
|
17
27
|
export {itemPageMeta, svgDimensions} from './meta.ts'
|
|
18
28
|
export type {ItemPageMeta, ItemPageMetaOptions} from './meta.ts'
|
|
19
29
|
|
|
20
|
-
// Tokens (consumed by
|
|
30
|
+
// Tokens (consumed by webapp tailwind.config)
|
|
21
31
|
export {tokens} from './tokens/index.ts'
|
|
22
32
|
export type {Tokens} from './tokens/index.ts'
|
|
23
33
|
export type {CategoryColorKey, TierColorKey} from './tokens/colors.ts'
|
package/src/links.ts
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import type {CargoItem} from './payload/codec.ts'
|
|
2
|
-
import {
|
|
2
|
+
import {encodeCargoItem, encodeNftPayload} from './payload/codec.ts'
|
|
3
3
|
|
|
4
4
|
const DEFAULT_WEBSITE_BASE = 'https://shiploadgame.com'
|
|
5
5
|
const DEFAULT_IMAGE_BASE = 'https://item.shiploadgame.com'
|
|
6
6
|
|
|
7
7
|
export function linkToItemPage(item: CargoItem, baseUrl = DEFAULT_WEBSITE_BASE): string {
|
|
8
|
-
const payload =
|
|
8
|
+
const payload = encodeCargoItem(item)
|
|
9
9
|
return `${baseUrl}/guide/item/${payload}`
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function linkToItemImage(
|
|
13
13
|
item: CargoItem,
|
|
14
14
|
ext: 'png' | 'svg',
|
|
15
|
-
baseUrl
|
|
15
|
+
opts?: {location?: {x: number | bigint; y: number | bigint}; baseUrl?: string}
|
|
16
16
|
): string {
|
|
17
|
-
const payload =
|
|
18
|
-
return `${baseUrl}/item/${payload}.${ext}`
|
|
17
|
+
const payload = encodeNftPayload({item, location: opts?.location ?? null})
|
|
18
|
+
return `${opts?.baseUrl ?? DEFAULT_IMAGE_BASE}/item/${payload}.${ext}`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function linkToItemSocial(
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export function linkToItemSocial(
|
|
22
|
+
item: CargoItem,
|
|
23
|
+
opts?: {location?: {x: number | bigint; y: number | bigint}; baseUrl?: string}
|
|
24
|
+
): string {
|
|
25
|
+
const payload = encodeNftPayload({item, location: opts?.location ?? null})
|
|
26
|
+
return `${opts?.baseUrl ?? DEFAULT_IMAGE_BASE}/social/${payload}.png`
|
|
24
27
|
}
|
package/src/meta.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function itemPageMeta(
|
|
|
32
32
|
return {
|
|
33
33
|
title: `${displayName(resolved)} · Shipload Guide`,
|
|
34
34
|
description: describeItem(resolved),
|
|
35
|
-
ogImage: linkToItemSocial(item, opts?.imageBaseUrl),
|
|
35
|
+
ogImage: linkToItemSocial(item, {baseUrl: opts?.imageBaseUrl}),
|
|
36
36
|
ogImageWidth: SOCIAL_CARD_WIDTH,
|
|
37
37
|
ogImageHeight: SOCIAL_CARD_HEIGHT,
|
|
38
38
|
}
|
package/src/payload/codec.ts
CHANGED
|
@@ -6,13 +6,16 @@ import {base64UrlToBytes, bytesToBase64Url} from './base64url.ts'
|
|
|
6
6
|
export type CargoItem = InstanceType<typeof ServerContract.Types.cargo_item>
|
|
7
7
|
export type CargoItemLike = Parameters<typeof ServerContract.Types.cargo_item.from>[0]
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export type NftItemPayload = InstanceType<typeof ServerContract.Types.nft_item_payload>
|
|
10
|
+
export type NftItemPayloadLike = Parameters<typeof ServerContract.Types.nft_item_payload.from>[0]
|
|
11
|
+
|
|
12
|
+
export function encodeCargoItem(input: CargoItemLike): string {
|
|
10
13
|
const item = ServerContract.Types.cargo_item.from(input)
|
|
11
14
|
const bytes = Serializer.encode({object: item}).array
|
|
12
15
|
return bytesToBase64Url(bytes)
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export function
|
|
18
|
+
export function decodeCargoItem(input: string): CargoItem {
|
|
16
19
|
if (input.length === 0) throw new InvalidPayloadError('empty payload')
|
|
17
20
|
const bytes = base64UrlToBytes(input)
|
|
18
21
|
try {
|
|
@@ -24,3 +27,22 @@ export function decodePayload(input: string): CargoItem {
|
|
|
24
27
|
throw new InvalidPayloadError(`cargo_item decode failed: ${(e as Error).message}`)
|
|
25
28
|
}
|
|
26
29
|
}
|
|
30
|
+
|
|
31
|
+
export function encodeNftPayload(input: NftItemPayloadLike): string {
|
|
32
|
+
const payload = ServerContract.Types.nft_item_payload.from(input)
|
|
33
|
+
const bytes = Serializer.encode({object: payload}).array
|
|
34
|
+
return bytesToBase64Url(bytes)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function decodeNftPayload(input: string): NftItemPayload {
|
|
38
|
+
if (input.length === 0) throw new InvalidPayloadError('empty payload')
|
|
39
|
+
const bytes = base64UrlToBytes(input)
|
|
40
|
+
try {
|
|
41
|
+
return Serializer.decode({
|
|
42
|
+
data: bytes,
|
|
43
|
+
type: ServerContract.Types.nft_item_payload,
|
|
44
|
+
}) as NftItemPayload
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new InvalidPayloadError(`nft_item_payload decode failed: ${(e as Error).message}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -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({
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
export function quantityBadge({
|
|
14
|
+
x,
|
|
15
|
+
y,
|
|
16
|
+
quantity,
|
|
17
|
+
label: labelOverride,
|
|
18
|
+
tone,
|
|
19
|
+
}: QuantityBadgeProps): string {
|
|
20
|
+
if (quantity <= 0) return ''
|
|
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/render.ts
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
import {resolveItem, type ResolvedItem} from '@shipload/sdk'
|
|
2
2
|
import type {CargoItem} from './payload/codec.ts'
|
|
3
|
-
import {
|
|
3
|
+
import {decodeNftPayload} from './payload/codec.ts'
|
|
4
4
|
import {renderByType} from './templates/index.ts'
|
|
5
5
|
import {UnknownItemError} from './errors.ts'
|
|
6
6
|
|
|
7
7
|
export interface RenderOptions {
|
|
8
8
|
width?: number
|
|
9
9
|
theme?: 'dark' | 'light'
|
|
10
|
+
location?: {x: number; y: number}
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function renderItem(item: CargoItem, resolved: ResolvedItem,
|
|
13
|
-
return renderByType(item, resolved)
|
|
13
|
+
export function renderItem(item: CargoItem, resolved: ResolvedItem, opts?: RenderOptions): string {
|
|
14
|
+
return renderByType(item, resolved, {location: opts?.location})
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export async function renderFromPayload(
|
|
17
18
|
payload: string,
|
|
18
19
|
opts?: RenderOptions
|
|
19
20
|
): Promise<{svg: string; item: ResolvedItem}> {
|
|
20
|
-
const
|
|
21
|
+
const decoded = decodeNftPayload(payload)
|
|
22
|
+
const cargo = decoded.item
|
|
21
23
|
let resolved: ResolvedItem
|
|
22
24
|
try {
|
|
23
|
-
resolved = resolveItem(
|
|
25
|
+
resolved = resolveItem(cargo.item_id, cargo.stats, cargo.modules)
|
|
24
26
|
} catch {
|
|
25
|
-
throw new UnknownItemError(Number(BigInt(
|
|
27
|
+
throw new UnknownItemError(Number(BigInt(cargo.item_id.toString())))
|
|
26
28
|
}
|
|
27
|
-
const
|
|
29
|
+
const location = decoded.location
|
|
30
|
+
? {x: Number(decoded.location.x), y: Number(decoded.location.y)}
|
|
31
|
+
: opts?.location
|
|
32
|
+
const svg = renderItem(cargo, resolved, {...opts, location})
|
|
28
33
|
return {svg, item: resolved}
|
|
29
34
|
}
|
package/src/templates/_shared.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
+
import {baseName, formatMassScaled} from '@shipload/sdk'
|
|
3
|
+
import {text} from '../primitives/text.ts'
|
|
4
|
+
import {divider} from '../primitives/divider.ts'
|
|
1
5
|
import {tokens} from '../tokens/index.ts'
|
|
2
6
|
|
|
3
|
-
export function formatMass(
|
|
4
|
-
return
|
|
7
|
+
export function formatMass(kg: number): string {
|
|
8
|
+
return formatMassScaled(kg)
|
|
5
9
|
}
|
|
6
10
|
|
|
7
11
|
export function tierBorder(tier: number): string {
|
|
@@ -12,3 +16,110 @@ export function shortCode(itemId: number): string {
|
|
|
12
16
|
const str = itemId.toString(10)
|
|
13
17
|
return str.slice(-2).padStart(2, '0')
|
|
14
18
|
}
|
|
19
|
+
|
|
20
|
+
export function capabilityColor(name: string): string {
|
|
21
|
+
const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
|
|
22
|
+
return tokens.colors.capability[key] ?? tokens.colors.accent.component
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const META_ROW_H = 22
|
|
26
|
+
export const HEADER_H = 48
|
|
27
|
+
export const ICON_Y = 4
|
|
28
|
+
export const BADGE_Y = 6
|
|
29
|
+
export const META_BLOCK_GAP = 16
|
|
30
|
+
|
|
31
|
+
export const STAT_ROW_H = 26
|
|
32
|
+
export const CAP_HEADER_H = 22
|
|
33
|
+
export const CAP_ROW_H = 18
|
|
34
|
+
export const BODY_TAIL = 8
|
|
35
|
+
|
|
36
|
+
// Gap from the meta block to the first stat row. Resources/components have no
|
|
37
|
+
// body sub-header, and statBar draws its label 6px above its y, so this is
|
|
38
|
+
// META_BLOCK_GAP plus the offset that puts the first stat label level with
|
|
39
|
+
// where module/entity body sections begin — keeping the meta→body gap uniform.
|
|
40
|
+
export const STAT_BLOCK_GAP = META_BLOCK_GAP + 22
|
|
41
|
+
|
|
42
|
+
// Uniform gap between a card's last body element and the bottom frame edge.
|
|
43
|
+
// Cards size their height to (last element bottom) + BOTTOM_PAD so trailing
|
|
44
|
+
// space is consistent across resource / component / module / entity types.
|
|
45
|
+
export const BOTTOM_PAD = 22
|
|
46
|
+
|
|
47
|
+
export function titleParts(x: number, y: number, name: string, tier: number): string {
|
|
48
|
+
return text({
|
|
49
|
+
x,
|
|
50
|
+
y,
|
|
51
|
+
value: name,
|
|
52
|
+
size: tokens.typography.sizes.title,
|
|
53
|
+
weight: 700,
|
|
54
|
+
family: tokens.typography.display,
|
|
55
|
+
spans: [
|
|
56
|
+
{
|
|
57
|
+
value: `T${tier}`,
|
|
58
|
+
dx: 6,
|
|
59
|
+
size: tokens.typography.sizes.subtitle,
|
|
60
|
+
weight: 700,
|
|
61
|
+
color: tokens.colors.text.secondary,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function titleText(x: number, y: number, resolved: ResolvedItem): string {
|
|
68
|
+
// Prominent base name; tier rendered as a smaller, muted inline suffix.
|
|
69
|
+
return titleParts(x, y, baseName(resolved), resolved.tier)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MetaRowProps {
|
|
73
|
+
x: number
|
|
74
|
+
y: number
|
|
75
|
+
width: number
|
|
76
|
+
label: string
|
|
77
|
+
value: string
|
|
78
|
+
showDivider?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function metaRow({x, y, width, label, value, showDivider = true}: MetaRowProps): string {
|
|
82
|
+
const labelText = text({
|
|
83
|
+
x,
|
|
84
|
+
y: y + 12,
|
|
85
|
+
value: label,
|
|
86
|
+
size: tokens.typography.sizes.body,
|
|
87
|
+
color: tokens.colors.text.secondary,
|
|
88
|
+
})
|
|
89
|
+
const valueText = text({
|
|
90
|
+
x: x + width,
|
|
91
|
+
y: y + 12,
|
|
92
|
+
value,
|
|
93
|
+
size: tokens.typography.sizes.body,
|
|
94
|
+
weight: 700,
|
|
95
|
+
anchor: 'end',
|
|
96
|
+
})
|
|
97
|
+
const sep = showDivider
|
|
98
|
+
? divider({
|
|
99
|
+
x,
|
|
100
|
+
y: y + META_ROW_H - 4,
|
|
101
|
+
width,
|
|
102
|
+
color: tokens.colors.surface.panelBorderBright,
|
|
103
|
+
})
|
|
104
|
+
: ''
|
|
105
|
+
return labelText + valueText + sep
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function metaRowBlock(
|
|
109
|
+
x: number,
|
|
110
|
+
yStart: number,
|
|
111
|
+
width: number,
|
|
112
|
+
rows: {label: string; value: string}[]
|
|
113
|
+
): {svg: string; height: number} {
|
|
114
|
+
let svg = ''
|
|
115
|
+
rows.forEach((row, i) => {
|
|
116
|
+
svg += metaRow({
|
|
117
|
+
x,
|
|
118
|
+
y: yStart + i * META_ROW_H,
|
|
119
|
+
width,
|
|
120
|
+
...row,
|
|
121
|
+
showDivider: i < rows.length - 1,
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
return {svg, height: rows.length * META_ROW_H}
|
|
125
|
+
}
|
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
import type {ResolvedItem
|
|
2
|
-
import {
|
|
1
|
+
import type {ResolvedItem} from '@shipload/sdk'
|
|
2
|
+
import {getRecipe, getStatDefinitions, resolveItemCategory, formatLocation} from '@shipload/sdk'
|
|
3
3
|
import type {CargoItem} from '../payload/codec.ts'
|
|
4
4
|
import {panel} from '../primitives/panel.ts'
|
|
5
5
|
import {iconHex} from '../primitives/icon-hex.ts'
|
|
6
|
-
import {text} from '../primitives/text.ts'
|
|
7
|
-
import {divider} from '../primitives/divider.ts'
|
|
8
6
|
import {statBar} from '../primitives/stat-bar.ts'
|
|
9
7
|
import {quantityBadge} from '../primitives/quantity-badge.ts'
|
|
10
8
|
import {tokens} from '../tokens/index.ts'
|
|
11
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
shortCode,
|
|
11
|
+
formatMass,
|
|
12
|
+
tierBorder,
|
|
13
|
+
metaRowBlock,
|
|
14
|
+
titleText,
|
|
15
|
+
BADGE_Y,
|
|
16
|
+
HEADER_H,
|
|
17
|
+
ICON_Y,
|
|
18
|
+
STAT_BLOCK_GAP,
|
|
19
|
+
STAT_ROW_H,
|
|
20
|
+
BOTTOM_PAD,
|
|
21
|
+
} from './_shared.ts'
|
|
12
22
|
|
|
13
23
|
export interface RenderComponentOpts {
|
|
14
24
|
mode?: 'values' | 'ranges'
|
|
25
|
+
location?: {x: number; y: number}
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
type StatRow = {
|
|
@@ -32,13 +43,15 @@ export function renderComponent(
|
|
|
32
43
|
const pad = tokens.spacing.panelPadding
|
|
33
44
|
const innerW = w - pad * 2
|
|
34
45
|
|
|
46
|
+
const identity = tokens.colors.accent.component
|
|
47
|
+
|
|
35
48
|
let rows: StatRow[]
|
|
36
49
|
if (mode === 'values') {
|
|
37
50
|
rows = (resolved.stats ?? []).map((s) => ({
|
|
38
51
|
label: s.label,
|
|
39
52
|
abbreviation: s.abbreviation,
|
|
40
53
|
value: s.value,
|
|
41
|
-
color:
|
|
54
|
+
color: identity,
|
|
42
55
|
inverted: s.inverted,
|
|
43
56
|
}))
|
|
44
57
|
} else {
|
|
@@ -47,8 +60,9 @@ export function renderComponent(
|
|
|
47
60
|
const src = slot.sources[0]
|
|
48
61
|
if (!src) return []
|
|
49
62
|
const input = recipe!.inputs[src.inputIndex]
|
|
50
|
-
if (!input
|
|
51
|
-
const category = input.
|
|
63
|
+
if (!input) return []
|
|
64
|
+
const category = resolveItemCategory(input.itemId)
|
|
65
|
+
if (!category) return []
|
|
52
66
|
const def = getStatDefinitions(category)[src.statIndex]
|
|
53
67
|
if (!def) return []
|
|
54
68
|
return [
|
|
@@ -56,78 +70,44 @@ export function renderComponent(
|
|
|
56
70
|
label: def.label,
|
|
57
71
|
abbreviation: def.abbreviation,
|
|
58
72
|
value: null,
|
|
59
|
-
color:
|
|
73
|
+
color: identity,
|
|
60
74
|
inverted: def.inverted,
|
|
61
75
|
},
|
|
62
76
|
]
|
|
63
77
|
})
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
const quantity = Number(BigInt(item.quantity.toString()))
|
|
81
|
+
const metaRows = [
|
|
82
|
+
{label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
|
|
83
|
+
...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
const metaYStart = pad + HEADER_H
|
|
87
|
+
const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
|
|
88
|
+
const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
|
|
89
|
+
const statsBottom =
|
|
90
|
+
statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
|
|
91
|
+
const height = statsBottom + BOTTOM_PAD
|
|
70
92
|
|
|
71
93
|
const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
|
|
72
94
|
|
|
73
|
-
const
|
|
74
|
-
const badge = quantityBadge({x: w - pad, y: pad, quantity})
|
|
95
|
+
const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: identity})
|
|
75
96
|
|
|
76
97
|
const icon = iconHex({
|
|
77
98
|
x: pad,
|
|
78
|
-
y: pad +
|
|
79
|
-
color:
|
|
99
|
+
y: pad + ICON_Y,
|
|
100
|
+
color: identity,
|
|
80
101
|
code: shortCode(resolved.itemId),
|
|
81
102
|
})
|
|
82
103
|
|
|
83
|
-
const name =
|
|
84
|
-
x: pad + 34,
|
|
85
|
-
y: pad + 22,
|
|
86
|
-
value: resolved.name,
|
|
87
|
-
size: tokens.typography.sizes.title,
|
|
88
|
-
weight: 700,
|
|
89
|
-
family: tokens.typography.display,
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
const subtitleText = text({
|
|
93
|
-
x: pad,
|
|
94
|
-
y: pad + headerH + 4,
|
|
95
|
-
value: 'Type',
|
|
96
|
-
size: tokens.typography.sizes.body,
|
|
97
|
-
color: tokens.colors.text.secondary,
|
|
98
|
-
})
|
|
99
|
-
const subtitleValue = text({
|
|
100
|
-
x: w - pad,
|
|
101
|
-
y: pad + headerH + 4,
|
|
102
|
-
value: `COMPONENT · ${formatTier(resolved.tier)}`,
|
|
103
|
-
size: tokens.typography.sizes.body,
|
|
104
|
-
weight: 600,
|
|
105
|
-
anchor: 'end',
|
|
106
|
-
})
|
|
107
|
-
const massLabel = text({
|
|
108
|
-
x: pad,
|
|
109
|
-
y: pad + headerH + metaRowH - 8,
|
|
110
|
-
value: 'Mass',
|
|
111
|
-
size: tokens.typography.sizes.body,
|
|
112
|
-
color: tokens.colors.text.secondary,
|
|
113
|
-
})
|
|
114
|
-
const massValue = text({
|
|
115
|
-
x: w - pad,
|
|
116
|
-
y: pad + headerH + metaRowH - 8,
|
|
117
|
-
value: formatMass(resolved.mass),
|
|
118
|
-
size: tokens.typography.sizes.body,
|
|
119
|
-
weight: 600,
|
|
120
|
-
anchor: 'end',
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
const sepY = pad + headerH + metaRowH + 6
|
|
124
|
-
const sep = divider({x: pad, y: sepY, width: innerW})
|
|
104
|
+
const name = titleText(pad + 34, pad + 22, resolved)
|
|
125
105
|
|
|
126
106
|
const statsSvg = rows
|
|
127
107
|
.map((row, i) =>
|
|
128
108
|
statBar({
|
|
129
109
|
x: pad,
|
|
130
|
-
y:
|
|
110
|
+
y: statsYStart + i * STAT_ROW_H,
|
|
131
111
|
width: innerW,
|
|
132
112
|
label: row.label,
|
|
133
113
|
abbreviation: row.abbreviation,
|
|
@@ -138,7 +118,7 @@ export function renderComponent(
|
|
|
138
118
|
)
|
|
139
119
|
.join('')
|
|
140
120
|
|
|
141
|
-
const inner = `${chrome}${icon}${name}${badge}${
|
|
121
|
+
const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
|
|
142
122
|
|
|
143
123
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
|
|
144
124
|
}
|
package/src/templates/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {renderModule} from './module.ts'
|
|
|
8
8
|
|
|
9
9
|
export interface RenderByTypeOpts {
|
|
10
10
|
mode?: 'values' | 'ranges'
|
|
11
|
+
location?: {x: number; y: number}
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function renderByType(
|
|
@@ -19,7 +20,7 @@ export function renderByType(
|
|
|
19
20
|
case 'resource':
|
|
20
21
|
return renderResource(item, resolved, opts)
|
|
21
22
|
case 'entity':
|
|
22
|
-
return renderPackedEntity(item, resolved)
|
|
23
|
+
return renderPackedEntity(item, resolved, opts)
|
|
23
24
|
case 'component':
|
|
24
25
|
return renderComponent(item, resolved, opts)
|
|
25
26
|
case 'module':
|