@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/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.30",
|
|
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.30",
|
|
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,17 +27,22 @@ 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'
|
|
24
34
|
|
|
25
|
-
//
|
|
26
|
-
export {
|
|
27
|
-
|
|
35
|
+
// Resource icon primitive
|
|
36
|
+
export {
|
|
37
|
+
resourceIcon,
|
|
38
|
+
resourceIconBody,
|
|
39
|
+
resourceIconCategories,
|
|
40
|
+
resourceIconSvg,
|
|
41
|
+
} from './primitives/resource-icon.ts'
|
|
42
|
+
export type {ResourceIconInlineOpts, ResourceIconSvgOpts} from './primitives/resource-icon.ts'
|
|
28
43
|
|
|
29
44
|
// Item cell templates
|
|
30
|
-
export {renderItemCell, itemCellGroup} from './templates/item-cell.ts'
|
|
45
|
+
export {renderItemCell, itemCellGroup, abbreviateQuantity} from './templates/item-cell.ts'
|
|
31
46
|
export type {ItemCellProps, ItemCellGroupProps} from './templates/item-cell.ts'
|
|
32
47
|
|
|
33
48
|
// Social card template (1200x630 OG image)
|
|
@@ -51,4 +66,3 @@ export {
|
|
|
51
66
|
type ResolvedModuleSlot,
|
|
52
67
|
type ResolvedAttributeGroup,
|
|
53
68
|
} from '@shipload/sdk'
|
|
54
|
-
export type {CategoryIconShape} from '@shipload/sdk'
|
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
|
)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {CATEGORY_LABELS, categoryColors, type ResourceCategory} from '@shipload/sdk'
|
|
2
|
+
import {el, escapeXml} from './svg.ts'
|
|
3
|
+
|
|
4
|
+
export const resourceIconCategories = [
|
|
5
|
+
'ore',
|
|
6
|
+
'crystal',
|
|
7
|
+
'gas',
|
|
8
|
+
'regolith',
|
|
9
|
+
'biomass',
|
|
10
|
+
] as const satisfies readonly ResourceCategory[]
|
|
11
|
+
|
|
12
|
+
export interface ResourceIconSvgOpts {
|
|
13
|
+
size?: number
|
|
14
|
+
title?: string
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResourceIconInlineOpts {
|
|
19
|
+
x: number
|
|
20
|
+
y: number
|
|
21
|
+
size: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OUTLINE = '#06142f'
|
|
25
|
+
const HILITE = '#f7fbff'
|
|
26
|
+
const OUTER_STROKE = 4
|
|
27
|
+
const DETAIL_STROKE = 2.5
|
|
28
|
+
|
|
29
|
+
function oreIcon(): string {
|
|
30
|
+
return [
|
|
31
|
+
el('path', {
|
|
32
|
+
d: 'M13 24 L25 11 L43 13 L55 27 L48 49 L27 56 L12 42 Z',
|
|
33
|
+
fill: categoryColors.ore,
|
|
34
|
+
stroke: OUTLINE,
|
|
35
|
+
'stroke-width': OUTER_STROKE,
|
|
36
|
+
'stroke-linejoin': 'round',
|
|
37
|
+
}),
|
|
38
|
+
el('path', {d: 'M25 11 L31 30 L13 24 Z', fill: '#e39a5e'}),
|
|
39
|
+
el('path', {d: 'M31 30 L43 13 L55 27 L41 32 Z', fill: '#b85b35'}),
|
|
40
|
+
el('path', {d: 'M31 30 L41 32 L48 49 L27 56 Z', fill: '#8f432e'}),
|
|
41
|
+
el('path', {
|
|
42
|
+
d: 'M31 30 L25 11 M31 30 L13 24 M31 30 L41 32 M41 32 L48 49 M31 30 L27 56',
|
|
43
|
+
fill: 'none',
|
|
44
|
+
stroke: OUTLINE,
|
|
45
|
+
'stroke-width': 1.5,
|
|
46
|
+
'stroke-linecap': 'round',
|
|
47
|
+
'stroke-linejoin': 'round',
|
|
48
|
+
opacity: 0.28,
|
|
49
|
+
}),
|
|
50
|
+
el('path', {
|
|
51
|
+
d: 'M21 23 L27 18 M42 20 L48 27',
|
|
52
|
+
fill: 'none',
|
|
53
|
+
stroke: '#ffd29c',
|
|
54
|
+
'stroke-width': DETAIL_STROKE,
|
|
55
|
+
'stroke-linecap': 'round',
|
|
56
|
+
opacity: 0.72,
|
|
57
|
+
}),
|
|
58
|
+
].join('')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function crystalIcon(): string {
|
|
62
|
+
return [
|
|
63
|
+
el('path', {
|
|
64
|
+
d: 'M32 6 L49 24 L39 58 L23 58 L14 25 Z',
|
|
65
|
+
fill: categoryColors.crystal,
|
|
66
|
+
stroke: OUTLINE,
|
|
67
|
+
'stroke-width': OUTER_STROKE,
|
|
68
|
+
'stroke-linejoin': 'round',
|
|
69
|
+
}),
|
|
70
|
+
el('path', {d: 'M32 6 L32 58 L14 25 Z', fill: '#1fb9e4'}),
|
|
71
|
+
el('path', {d: 'M32 6 L49 24 L32 58 Z', fill: '#8df0ff'}),
|
|
72
|
+
el('path', {d: 'M23 58 L32 32 L39 58 Z', fill: '#2f87d7', opacity: 0.72}),
|
|
73
|
+
el('path', {
|
|
74
|
+
d: 'M32 6 L32 58 M14 25 L32 32 L49 24 M23 58 L32 32 L39 58',
|
|
75
|
+
fill: 'none',
|
|
76
|
+
stroke: OUTLINE,
|
|
77
|
+
'stroke-width': 1.5,
|
|
78
|
+
'stroke-linecap': 'round',
|
|
79
|
+
'stroke-linejoin': 'round',
|
|
80
|
+
opacity: 0.25,
|
|
81
|
+
}),
|
|
82
|
+
el('path', {
|
|
83
|
+
d: 'M25 20 L31 13 M38 19 L43 25',
|
|
84
|
+
fill: 'none',
|
|
85
|
+
stroke: HILITE,
|
|
86
|
+
'stroke-width': DETAIL_STROKE,
|
|
87
|
+
'stroke-linecap': 'round',
|
|
88
|
+
opacity: 0.78,
|
|
89
|
+
}),
|
|
90
|
+
].join('')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function gasIcon(): string {
|
|
94
|
+
return [
|
|
95
|
+
el('circle', {
|
|
96
|
+
cx: 32,
|
|
97
|
+
cy: 32,
|
|
98
|
+
r: 23,
|
|
99
|
+
fill: categoryColors.gas,
|
|
100
|
+
stroke: OUTLINE,
|
|
101
|
+
'stroke-width': OUTER_STROKE,
|
|
102
|
+
}),
|
|
103
|
+
el('path', {
|
|
104
|
+
d: 'M17 33 C21 20 38 18 44 27 C51 38 37 51 25 43',
|
|
105
|
+
fill: 'none',
|
|
106
|
+
stroke: '#7a48d9',
|
|
107
|
+
'stroke-width': 7.5,
|
|
108
|
+
'stroke-linecap': 'round',
|
|
109
|
+
}),
|
|
110
|
+
el('path', {
|
|
111
|
+
d: 'M21 33 C25 25 36 25 39 31 C43 38 35 44 28 39',
|
|
112
|
+
fill: 'none',
|
|
113
|
+
stroke: '#f0d7ff',
|
|
114
|
+
'stroke-width': 5,
|
|
115
|
+
'stroke-linecap': 'round',
|
|
116
|
+
}),
|
|
117
|
+
el('circle', {
|
|
118
|
+
cx: 44,
|
|
119
|
+
cy: 18,
|
|
120
|
+
r: 5,
|
|
121
|
+
fill: '#f0d7ff',
|
|
122
|
+
stroke: OUTLINE,
|
|
123
|
+
'stroke-width': DETAIL_STROKE,
|
|
124
|
+
}),
|
|
125
|
+
el('circle', {
|
|
126
|
+
cx: 19,
|
|
127
|
+
cy: 47,
|
|
128
|
+
r: 4,
|
|
129
|
+
fill: '#f0d7ff',
|
|
130
|
+
stroke: OUTLINE,
|
|
131
|
+
'stroke-width': DETAIL_STROKE,
|
|
132
|
+
}),
|
|
133
|
+
].join('')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function regolithIcon(): string {
|
|
137
|
+
return [
|
|
138
|
+
el('path', {
|
|
139
|
+
d: 'M16 14 H45 L54 25 V46 L43 55 H17 L9 43 V24 Z',
|
|
140
|
+
fill: categoryColors.regolith,
|
|
141
|
+
stroke: OUTLINE,
|
|
142
|
+
'stroke-width': OUTER_STROKE,
|
|
143
|
+
'stroke-linejoin': 'round',
|
|
144
|
+
}),
|
|
145
|
+
el('path', {d: 'M16 14 H45 L54 25 L34 28 Z', fill: '#dec59a'}),
|
|
146
|
+
el('path', {d: 'M9 43 L34 28 L43 55 H17 Z', fill: '#9e7c55', opacity: 0.78}),
|
|
147
|
+
el('path', {
|
|
148
|
+
d: 'M16 14 L34 28 L54 25 M34 28 L43 55 M34 28 L9 43',
|
|
149
|
+
fill: 'none',
|
|
150
|
+
stroke: OUTLINE,
|
|
151
|
+
'stroke-width': 1.5,
|
|
152
|
+
'stroke-linecap': 'round',
|
|
153
|
+
'stroke-linejoin': 'round',
|
|
154
|
+
opacity: 0.25,
|
|
155
|
+
}),
|
|
156
|
+
el('circle', {cx: 25, cy: 28, r: 4, fill: '#806248', stroke: OUTLINE, 'stroke-width': 1.5}),
|
|
157
|
+
el('circle', {cx: 42, cy: 40, r: 5, fill: '#8b6a4d', stroke: OUTLINE, 'stroke-width': 1.5}),
|
|
158
|
+
el('circle', {
|
|
159
|
+
cx: 22,
|
|
160
|
+
cy: 44,
|
|
161
|
+
r: 3,
|
|
162
|
+
fill: '#e7d0a9',
|
|
163
|
+
stroke: OUTLINE,
|
|
164
|
+
'stroke-width': 1.25,
|
|
165
|
+
}),
|
|
166
|
+
el('path', {
|
|
167
|
+
d: 'M35 18 L45 23',
|
|
168
|
+
fill: 'none',
|
|
169
|
+
stroke: '#f0dbb7',
|
|
170
|
+
'stroke-width': DETAIL_STROKE,
|
|
171
|
+
'stroke-linecap': 'round',
|
|
172
|
+
opacity: 0.72,
|
|
173
|
+
}),
|
|
174
|
+
].join('')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function biomassIcon(): string {
|
|
178
|
+
return [
|
|
179
|
+
el('path', {
|
|
180
|
+
d: 'M32 7 C37 14 42 17 50 16 C50 24 54 29 57 35 C51 39 48 44 47 52 C39 50 36 55 31 59 C27 52 21 50 13 53 C14 45 10 40 7 34 C13 29 14 23 12 16 C21 18 26 14 32 7 Z',
|
|
181
|
+
fill: categoryColors.biomass,
|
|
182
|
+
stroke: OUTLINE,
|
|
183
|
+
'stroke-width': OUTER_STROKE,
|
|
184
|
+
'stroke-linejoin': 'round',
|
|
185
|
+
}),
|
|
186
|
+
el('circle', {cx: 32, cy: 32, r: 13, fill: '#73ad49', stroke: OUTLINE, 'stroke-width': 2}),
|
|
187
|
+
el('circle', {cx: 27, cy: 28, r: 5, fill: '#a8db6f', stroke: OUTLINE, 'stroke-width': 1.5}),
|
|
188
|
+
el('circle', {cx: 38, cy: 34, r: 5, fill: '#2f6f35', stroke: OUTLINE, 'stroke-width': 1.5}),
|
|
189
|
+
el('circle', {cx: 30, cy: 41, r: 4, fill: '#89c85a', stroke: OUTLINE, 'stroke-width': 1.5}),
|
|
190
|
+
el('circle', {
|
|
191
|
+
cx: 40,
|
|
192
|
+
cy: 24,
|
|
193
|
+
r: 3,
|
|
194
|
+
fill: '#c9ef8a',
|
|
195
|
+
stroke: OUTLINE,
|
|
196
|
+
'stroke-width': 1.25,
|
|
197
|
+
}),
|
|
198
|
+
].join('')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const iconBodies: Record<ResourceCategory, string> = {
|
|
202
|
+
ore: oreIcon(),
|
|
203
|
+
crystal: crystalIcon(),
|
|
204
|
+
gas: gasIcon(),
|
|
205
|
+
regolith: regolithIcon(),
|
|
206
|
+
biomass: biomassIcon(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resourceIconBody(category: ResourceCategory): string {
|
|
210
|
+
return iconBodies[category]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resourceIcon(category: ResourceCategory, opts: ResourceIconInlineOpts): string {
|
|
214
|
+
const scale = opts.size / 64
|
|
215
|
+
return el(
|
|
216
|
+
'g',
|
|
217
|
+
{transform: `translate(${opts.x} ${opts.y}) scale(${scale})`, 'data-resource': category},
|
|
218
|
+
resourceIconBody(category)
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function resourceIconSvg(
|
|
223
|
+
category: ResourceCategory,
|
|
224
|
+
opts: ResourceIconSvgOpts = {}
|
|
225
|
+
): string {
|
|
226
|
+
const size = opts.size ?? 64
|
|
227
|
+
const title = opts.title ?? `${CATEGORY_LABELS[category]} resource icon`
|
|
228
|
+
const children = `<title>${escapeXml(title)}</title>${resourceIconBody(category)}`
|
|
229
|
+
return el(
|
|
230
|
+
'svg',
|
|
231
|
+
{
|
|
232
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
233
|
+
width: size,
|
|
234
|
+
height: size,
|
|
235
|
+
viewBox: '0 0 64 64',
|
|
236
|
+
role: 'img',
|
|
237
|
+
class: opts.className,
|
|
238
|
+
'aria-label': title,
|
|
239
|
+
},
|
|
240
|
+
children
|
|
241
|
+
)
|
|
242
|
+
}
|
|
@@ -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('')
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {el} from './svg.ts'
|
|
2
2
|
import {text} from './text.ts'
|
|
3
3
|
import {tokens} from '../tokens/index.ts'
|
|
4
|
+
import {statStars, STAR_BLOCK_WIDTH} from './stat-stars.ts'
|
|
4
5
|
|
|
5
6
|
export interface StatBarProps {
|
|
6
7
|
x: number
|
|
@@ -11,6 +12,7 @@ export interface StatBarProps {
|
|
|
11
12
|
value: number | null // 0..1023, or null for ranges mode (no value text, no fill)
|
|
12
13
|
color: string
|
|
13
14
|
inverted?: boolean
|
|
15
|
+
stars?: number // 0..3 per-stat rating; only drawn in values mode
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function statBar({
|
|
@@ -22,6 +24,7 @@ export function statBar({
|
|
|
22
24
|
value,
|
|
23
25
|
color,
|
|
24
26
|
inverted,
|
|
27
|
+
stars,
|
|
25
28
|
}: StatBarProps): string {
|
|
26
29
|
const h = tokens.spacing.statBarHeight
|
|
27
30
|
|
|
@@ -59,16 +62,29 @@ export function statBar({
|
|
|
59
62
|
const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
|
|
60
63
|
const filled = Math.floor(width * displayFraction)
|
|
61
64
|
|
|
65
|
+
const VALUE_COL_W = 34 // room for up to 4 mono digits at size 14, anchored end
|
|
66
|
+
|
|
67
|
+
// value text = primary; identity color = bar + code + chrome
|
|
62
68
|
labelOut += text({
|
|
63
69
|
x: x + width,
|
|
64
70
|
y: y - 6,
|
|
65
71
|
value: String(clamped),
|
|
66
72
|
size: tokens.typography.sizes.statValue,
|
|
67
73
|
weight: 700,
|
|
68
|
-
|
|
74
|
+
family: tokens.typography.mono,
|
|
75
|
+
color: tokens.colors.text.primary,
|
|
69
76
|
anchor: 'end',
|
|
70
77
|
})
|
|
71
78
|
|
|
79
|
+
// stars sit immediately left of the reserved value column
|
|
80
|
+
if (stars !== undefined) {
|
|
81
|
+
labelOut += statStars({
|
|
82
|
+
x: x + width - VALUE_COL_W - STAR_BLOCK_WIDTH,
|
|
83
|
+
y: y - 6,
|
|
84
|
+
n: stars,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
const bar = el('rect', {
|
|
73
89
|
x,
|
|
74
90
|
y,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {el} from './svg.ts'
|
|
2
|
+
|
|
3
|
+
// 24×24 path, identical to the webapp ItemStatRow star.
|
|
4
|
+
const STAR_PATH =
|
|
5
|
+
'M12 2.2l2.95 5.98 6.6.96-4.77 4.65 1.13 6.57L12 17.23 6.09 20.36l1.13-6.57L2.45 9.14l6.6-.96z'
|
|
6
|
+
|
|
7
|
+
export const STAR_SIZE = 9
|
|
8
|
+
export const STAR_GAP = 1
|
|
9
|
+
export const MAX_STARS = 3
|
|
10
|
+
export const STAR_BLOCK_WIDTH = MAX_STARS * STAR_SIZE + (MAX_STARS - 1) * STAR_GAP
|
|
11
|
+
|
|
12
|
+
const ON_COLOR = '#ffce5c'
|
|
13
|
+
const EMPTY_COLOR = 'rgba(255,255,255,0.16)'
|
|
14
|
+
const SCALE = STAR_SIZE / 24
|
|
15
|
+
|
|
16
|
+
export interface StatStarsProps {
|
|
17
|
+
x: number // left edge of the star block
|
|
18
|
+
y: number // vertical center the stars should sit on (text baseline)
|
|
19
|
+
n: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Draws MAX_STARS glyphs left-to-right starting at x; the first `n` are filled.
|
|
23
|
+
export function statStars({x, y, n}: StatStarsProps): string {
|
|
24
|
+
const filled = Math.max(0, Math.min(MAX_STARS, Math.floor(n)))
|
|
25
|
+
const top = y - STAR_SIZE + 1 // center the glyph on the text baseline
|
|
26
|
+
let out = ''
|
|
27
|
+
for (let i = 0; i < MAX_STARS; i++) {
|
|
28
|
+
const gx = x + i * (STAR_SIZE + STAR_GAP)
|
|
29
|
+
out += el('path', {
|
|
30
|
+
d: STAR_PATH,
|
|
31
|
+
transform: `translate(${gx} ${top}) scale(${SCALE})`,
|
|
32
|
+
fill: i < filled ? ON_COLOR : EMPTY_COLOR,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
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
|
}
|