@shipload/item-renderer 0.1.0

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.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/.gitignore +6 -0
  3. package/Makefile +50 -0
  4. package/biome.json +18 -0
  5. package/bun.lock +123 -0
  6. package/package.json +51 -0
  7. package/scripts/check-bundle-size.ts +37 -0
  8. package/scripts/copy-fonts.ts +41 -0
  9. package/scripts/preview.ts +43 -0
  10. package/src/errors.ts +22 -0
  11. package/src/fonts/index.ts +36 -0
  12. package/src/fonts/inter-400.woff2 +0 -0
  13. package/src/fonts/inter-600.woff2 +0 -0
  14. package/src/fonts/jetbrains-500.woff2 +0 -0
  15. package/src/fonts/load-bun.ts +16 -0
  16. package/src/fonts/orbitron-700.woff2 +0 -0
  17. package/src/index.ts +46 -0
  18. package/src/links.ts +19 -0
  19. package/src/meta.ts +42 -0
  20. package/src/payload/base64url.ts +27 -0
  21. package/src/payload/codec.ts +26 -0
  22. package/src/primitives/category-icon.ts +87 -0
  23. package/src/primitives/compact-row.ts +38 -0
  24. package/src/primitives/divider.ts +20 -0
  25. package/src/primitives/icon-hex.ts +39 -0
  26. package/src/primitives/module-slot.ts +147 -0
  27. package/src/primitives/panel.ts +24 -0
  28. package/src/primitives/quantity-badge.ts +37 -0
  29. package/src/primitives/span-paragraph.ts +72 -0
  30. package/src/primitives/stat-bar.ts +85 -0
  31. package/src/primitives/svg.ts +25 -0
  32. package/src/primitives/text.ts +42 -0
  33. package/src/primitives/wrap.ts +24 -0
  34. package/src/render.ts +33 -0
  35. package/src/templates/_shared.ts +15 -0
  36. package/src/templates/component.ts +139 -0
  37. package/src/templates/index.ts +30 -0
  38. package/src/templates/item-cell.ts +96 -0
  39. package/src/templates/module.ts +190 -0
  40. package/src/templates/packed-entity.ts +30 -0
  41. package/src/templates/resource.ts +151 -0
  42. package/src/templates/ship-panel.ts +145 -0
  43. package/src/tokens/colors.ts +45 -0
  44. package/src/tokens/index.ts +7 -0
  45. package/src/tokens/spacing.ts +10 -0
  46. package/src/tokens/typography.ts +19 -0
  47. package/test/__image_snapshots__/.gitkeep +0 -0
  48. package/test/__image_snapshots__/component-hull-plates.png +0 -0
  49. package/test/__image_snapshots__/module-engine-t1.png +0 -0
  50. package/test/__image_snapshots__/module-storage-t1.png +0 -0
  51. package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.png +0 -0
  52. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
  53. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.png +0 -0
  54. package/test/__image_snapshots__/resource-iron.diff.png +0 -0
  55. package/test/__image_snapshots__/resource-iron.png +0 -0
  56. package/test/__snapshots__/templates-component.test.ts.snap +5 -0
  57. package/test/__snapshots__/templates-item-cell.test.ts.snap +9 -0
  58. package/test/__snapshots__/templates-module.test.ts.snap +17 -0
  59. package/test/__snapshots__/templates-packed-entity.test.ts.snap +5 -0
  60. package/test/__snapshots__/templates-resource.test.ts.snap +7 -0
  61. package/test/base64url.test.ts +33 -0
  62. package/test/codec.test.ts +43 -0
  63. package/test/errors.test.ts +24 -0
  64. package/test/fixtures/cargo-items.ts +122 -0
  65. package/test/fonts.test.ts +28 -0
  66. package/test/links-meta.test.ts +34 -0
  67. package/test/pixel.test.ts +66 -0
  68. package/test/primitives-category-icon.test.ts +79 -0
  69. package/test/primitives-compact-row.test.ts +44 -0
  70. package/test/primitives-domain.test.ts +72 -0
  71. package/test/primitives-layout.test.ts +56 -0
  72. package/test/primitives-module-slot.test.ts +88 -0
  73. package/test/render.test.ts +40 -0
  74. package/test/sanity.test.ts +6 -0
  75. package/test/sdk-link.test.ts +19 -0
  76. package/test/snapshots/.gitkeep +0 -0
  77. package/test/svg.test.ts +28 -0
  78. package/test/templates-component.test.ts +36 -0
  79. package/test/templates-dispatch.test.ts +35 -0
  80. package/test/templates-item-cell.test.ts +94 -0
  81. package/test/templates-module.test.ts +63 -0
  82. package/test/templates-packed-entity.test.ts +47 -0
  83. package/test/templates-resource.test.ts +71 -0
  84. package/test/templates-ship-panel.test.ts +87 -0
  85. package/test/tokens.test.ts +32 -0
  86. package/tsconfig.json +20 -0
@@ -0,0 +1,87 @@
1
+ import type { CategoryIconShape } from '@shipload/sdk'
2
+ import { el } from './svg.ts'
3
+
4
+ export interface CategoryIconPathOpts {
5
+ shape: CategoryIconShape
6
+ cx: number
7
+ cy: number
8
+ size: number
9
+ color: string
10
+ strokeWidth?: number
11
+ }
12
+
13
+ export interface CategoryIconSvgOpts {
14
+ size?: number
15
+ color?: string
16
+ strokeWidth?: number
17
+ }
18
+
19
+ function hexPoints(cx: number, cy: number, r: number): string {
20
+ const pts: string[] = []
21
+ for (let i = 0; i < 6; i++) {
22
+ const angle = (Math.PI / 3) * i - Math.PI / 2
23
+ pts.push(`${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`)
24
+ }
25
+ return pts.join(' ')
26
+ }
27
+
28
+ function diamondPoints(cx: number, cy: number, r: number): string {
29
+ return [
30
+ `${cx.toFixed(2)},${(cy - r).toFixed(2)}`,
31
+ `${(cx + r).toFixed(2)},${cy.toFixed(2)}`,
32
+ `${cx.toFixed(2)},${(cy + r).toFixed(2)}`,
33
+ `${(cx - r).toFixed(2)},${cy.toFixed(2)}`,
34
+ ].join(' ')
35
+ }
36
+
37
+ function starPoints(cx: number, cy: number, r: number): string {
38
+ const inner = r * 0.45
39
+ const pts: string[] = []
40
+ for (let i = 0; i < 10; i++) {
41
+ const angle = (Math.PI / 5) * i - Math.PI / 2
42
+ const radius = i % 2 === 0 ? r : inner
43
+ pts.push(`${(cx + radius * Math.cos(angle)).toFixed(2)},${(cy + radius * Math.sin(angle)).toFixed(2)}`)
44
+ }
45
+ return pts.join(' ')
46
+ }
47
+
48
+ export function categoryIconPath({ shape, cx, cy, size, color, strokeWidth }: CategoryIconPathOpts): string {
49
+ const r = size / 2
50
+ const stroked = strokeWidth && strokeWidth > 0
51
+ const shapeAttrs = stroked
52
+ ? { fill: 'none', stroke: color, 'stroke-width': strokeWidth, 'stroke-linejoin': 'round' as const }
53
+ : { fill: color }
54
+ switch (shape) {
55
+ case 'hex':
56
+ return el('polygon', { points: hexPoints(cx, cy, r), ...shapeAttrs })
57
+ case 'diamond':
58
+ return el('polygon', { points: diamondPoints(cx, cy, r), ...shapeAttrs })
59
+ case 'star':
60
+ return el('polygon', { points: starPoints(cx, cy, r), ...shapeAttrs })
61
+ case 'circle':
62
+ return el('circle', { cx, cy, r, ...shapeAttrs })
63
+ case 'square':
64
+ return el('rect', { x: cx - r, y: cy - r, width: size, height: size, ...shapeAttrs })
65
+ }
66
+ const _exhaustive: never = shape
67
+ throw new Error(`Unknown CategoryIconShape: ${String(_exhaustive)}`)
68
+ }
69
+
70
+ export function categoryIconSvg(shape: CategoryIconShape, opts: CategoryIconSvgOpts = {}): string {
71
+ const size = opts.size ?? 16
72
+ const color = opts.color ?? '#ffffff'
73
+ const cx = size / 2
74
+ const cy = size / 2
75
+ const iconSize = size * 0.85
76
+ const inner = categoryIconPath({ shape, cx, cy, size: iconSize, color, strokeWidth: opts.strokeWidth })
77
+ return el(
78
+ 'svg',
79
+ {
80
+ xmlns: 'http://www.w3.org/2000/svg',
81
+ width: size,
82
+ height: size,
83
+ viewBox: `0 0 ${size} ${size}`,
84
+ },
85
+ inner,
86
+ )
87
+ }
@@ -0,0 +1,38 @@
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
+ }
@@ -0,0 +1,20 @@
1
+ import { el } from './svg.ts'
2
+ import { tokens } from '../tokens/index.ts'
3
+
4
+ export interface DividerProps {
5
+ x: number
6
+ y: number
7
+ width: number
8
+ color?: string
9
+ }
10
+
11
+ export function divider(props: DividerProps): string {
12
+ return el('line', {
13
+ x1: props.x,
14
+ x2: props.x + props.width,
15
+ y1: props.y,
16
+ y2: props.y,
17
+ stroke: props.color ?? tokens.colors.surface.panelBorder,
18
+ 'stroke-width': 1,
19
+ })
20
+ }
@@ -0,0 +1,39 @@
1
+ import { el } from './svg.ts'
2
+ import { text } from './text.ts'
3
+ import { tokens } from '../tokens/index.ts'
4
+
5
+ export interface IconHexProps {
6
+ x: number
7
+ y: number
8
+ color: string
9
+ code: string
10
+ }
11
+
12
+ export function iconHex({ x, y, color, code }: IconHexProps): string {
13
+ const size = tokens.spacing.iconHexSize
14
+ const h = size
15
+ const w = size * 1.1547 // flat-top hex aspect
16
+ const cx = x + w / 2
17
+ const cy = y + h / 2
18
+ const points = [
19
+ [cx - w / 2, cy],
20
+ [cx - w / 4, cy - h / 2],
21
+ [cx + w / 4, cy - h / 2],
22
+ [cx + w / 2, cy],
23
+ [cx + w / 4, cy + h / 2],
24
+ [cx - w / 4, cy + h / 2],
25
+ ].map(([px, py]) => `${px?.toFixed(1)},${py?.toFixed(1)}`).join(' ')
26
+ return (
27
+ el('polygon', { points, fill: 'none', stroke: color, 'stroke-width': 1.5 }) +
28
+ text({
29
+ x: cx,
30
+ y: cy + 3,
31
+ value: code,
32
+ size: 9,
33
+ weight: 700,
34
+ family: tokens.typography.mono,
35
+ color,
36
+ anchor: 'middle',
37
+ })
38
+ )
39
+ }
@@ -0,0 +1,147 @@
1
+ import { el } from './svg.ts'
2
+ import { text } from './text.ts'
3
+ import { wrapText } from './wrap.ts'
4
+ import { tokens } from '../tokens/index.ts'
5
+ import type { TextSpan } from '@shipload/sdk'
6
+
7
+ export interface ModuleSlotProps {
8
+ x: number
9
+ y: number
10
+ width: number
11
+ installed: boolean
12
+ capability?: string
13
+ description?: string | TextSpan[]
14
+ accentColor?: string
15
+ }
16
+
17
+ const EMPTY_DIAMOND = (cx: number, cy: number, color: string) =>
18
+ el('polygon', {
19
+ points: `${cx},${cy - 5} ${cx + 5},${cy} ${cx},${cy + 5} ${cx - 5},${cy}`,
20
+ fill: 'none',
21
+ stroke: color,
22
+ 'stroke-width': 1,
23
+ })
24
+
25
+ const FILLED_DIAMOND = (cx: number, cy: number, color: string) =>
26
+ el('polygon', {
27
+ points: `${cx},${cy - 5} ${cx + 5},${cy} ${cx},${cy + 5} ${cx - 5},${cy}`,
28
+ fill: color,
29
+ })
30
+
31
+ function escapeXml(s: string): string {
32
+ return s
33
+ .replace(/&/g, '&amp;')
34
+ .replace(/</g, '&lt;')
35
+ .replace(/>/g, '&gt;')
36
+ .replace(/"/g, '&quot;')
37
+ }
38
+
39
+ function sliceSpans(spans: TextSpan[], start: number, end: number): TextSpan[] {
40
+ const out: TextSpan[] = []
41
+ let cursor = 0
42
+ for (const span of spans) {
43
+ const spanStart = cursor
44
+ const spanEnd = cursor + span.text.length
45
+ cursor = spanEnd
46
+ if (spanEnd <= start || spanStart >= end) continue
47
+ const sliceStart = Math.max(0, start - spanStart)
48
+ const sliceEnd = span.text.length - Math.max(0, spanEnd - end)
49
+ const txt = span.text.slice(sliceStart, sliceEnd)
50
+ if (txt.length === 0) continue
51
+ out.push(span.highlight ? { text: txt, highlight: true } : { text: txt })
52
+ }
53
+ return out
54
+ }
55
+
56
+ export function moduleSlot(props: ModuleSlotProps): string {
57
+ const iconX = props.x + 6
58
+ const iconY = props.y + 6
59
+ const textX = props.x + 20
60
+
61
+ if (!props.installed) {
62
+ return (
63
+ EMPTY_DIAMOND(iconX, iconY, tokens.colors.surface.panelBorderBright) +
64
+ text({
65
+ x: textX,
66
+ y: iconY + 3,
67
+ value: 'Empty module',
68
+ size: tokens.typography.sizes.body,
69
+ color: tokens.colors.text.muted,
70
+ })
71
+ )
72
+ }
73
+
74
+ const accent = props.accentColor ?? tokens.colors.text.accent
75
+ const label = `${props.capability ?? 'Module'}: `
76
+
77
+ const desc = props.description
78
+ const isEmpty =
79
+ !desc ||
80
+ (typeof desc === 'string' && desc.length === 0) ||
81
+ (Array.isArray(desc) && desc.length === 0)
82
+
83
+ if (isEmpty) {
84
+ return (
85
+ FILLED_DIAMOND(iconX, iconY, accent) +
86
+ text({
87
+ x: textX,
88
+ y: iconY + 3,
89
+ value: label.trimEnd(),
90
+ size: tokens.typography.sizes.body,
91
+ weight: 600,
92
+ color: tokens.colors.text.primary,
93
+ })
94
+ )
95
+ }
96
+
97
+ const descSpans: TextSpan[] = typeof desc === 'string' ? [{ text: desc }] : desc
98
+ const descPlain = descSpans.map((s) => s.text).join('')
99
+ const combined = label + descPlain
100
+ const lines = wrapText({ value: combined, charsPerLine: 36 })
101
+
102
+ const highlightColor = tokens.colors.text.accent
103
+ const bodyColor = tokens.colors.text.secondary
104
+ const labelColor = tokens.colors.text.primary
105
+ const size = tokens.typography.sizes.body
106
+ const fontFamily = escapeXml(tokens.typography.sans)
107
+ const labelEnd = label.length
108
+
109
+ let offset = 0
110
+ const textBlocks = lines
111
+ .map((line, i) => {
112
+ const lineStart = combined.indexOf(line, offset)
113
+ const lineEnd = lineStart + line.length
114
+ offset = lineEnd
115
+ const y = iconY + 3 + i * 14
116
+
117
+ const tspans: string[] = []
118
+
119
+ if (lineStart < labelEnd) {
120
+ const labelSliceEnd = Math.min(lineEnd, labelEnd)
121
+ const labelText = combined.slice(lineStart, labelSliceEnd)
122
+ if (labelText.length > 0) {
123
+ tspans.push(
124
+ `<tspan font-weight="600" fill="${labelColor}">${escapeXml(labelText)}</tspan>`,
125
+ )
126
+ }
127
+ if (lineEnd > labelEnd) {
128
+ const descSlice = sliceSpans(descSpans, 0, lineEnd - labelEnd)
129
+ for (const s of descSlice) {
130
+ const fill = s.highlight ? highlightColor : bodyColor
131
+ tspans.push(`<tspan fill="${fill}">${escapeXml(s.text)}</tspan>`)
132
+ }
133
+ }
134
+ } else {
135
+ const descSlice = sliceSpans(descSpans, lineStart - labelEnd, lineEnd - labelEnd)
136
+ for (const s of descSlice) {
137
+ const fill = s.highlight ? highlightColor : bodyColor
138
+ tspans.push(`<tspan fill="${fill}">${escapeXml(s.text)}</tspan>`)
139
+ }
140
+ }
141
+
142
+ return `<text x="${textX}" y="${y}" font-family="${fontFamily}" font-size="${size}">${tspans.join('')}</text>`
143
+ })
144
+ .join('')
145
+
146
+ return FILLED_DIAMOND(iconX, iconY, accent) + textBlocks
147
+ }
@@ -0,0 +1,24 @@
1
+ import { el } from './svg.ts'
2
+ import { tokens } from '../tokens/index.ts'
3
+
4
+ export interface PanelProps {
5
+ width: number
6
+ height: number
7
+ borderColor?: string
8
+ }
9
+
10
+ export function panel(props: PanelProps): string {
11
+ const { width, height, borderColor } = props
12
+ const r = tokens.spacing.cornerRadius
13
+ return el('rect', {
14
+ x: 0.5,
15
+ y: 0.5,
16
+ width: width - 1,
17
+ height: height - 1,
18
+ rx: r,
19
+ ry: r,
20
+ fill: tokens.colors.surface.panel,
21
+ stroke: borderColor ?? tokens.colors.surface.panelBorder,
22
+ 'stroke-width': 1,
23
+ })
24
+ }
@@ -0,0 +1,37 @@
1
+ import { el } from './svg.ts'
2
+ import { text } from './text.ts'
3
+ import { tokens } from '../tokens/index.ts'
4
+
5
+ export interface QuantityBadgeProps {
6
+ x: number
7
+ y: number
8
+ quantity: number
9
+ }
10
+
11
+ export function quantityBadge({ x, y, quantity }: QuantityBadgeProps): string {
12
+ if (quantity <= 1) return ''
13
+ const label = `×${quantity}`
14
+ const w = label.length * 7 + 12
15
+ const h = tokens.spacing.quantityBadgeHeight
16
+ return (
17
+ el('rect', {
18
+ x: x - w,
19
+ y,
20
+ width: w,
21
+ height: h,
22
+ rx: h / 2,
23
+ ry: h / 2,
24
+ fill: tokens.colors.text.accent,
25
+ }) +
26
+ text({
27
+ x: x - w / 2,
28
+ y: y + h / 2 + 4,
29
+ value: label,
30
+ size: tokens.typography.sizes.label,
31
+ weight: 700,
32
+ family: tokens.typography.mono,
33
+ color: tokens.colors.surface.background,
34
+ anchor: 'middle',
35
+ })
36
+ )
37
+ }
@@ -0,0 +1,72 @@
1
+ import { wrapText } from './wrap.ts'
2
+ import { escapeXml as escapeAttr } from './svg.ts'
3
+ import { tokens } from '../tokens/index.ts'
4
+ import type { TextSpan } from '@shipload/sdk'
5
+
6
+ export interface SpanParagraphProps {
7
+ x: number
8
+ y: number
9
+ spans: TextSpan[]
10
+ charsPerLine?: number
11
+ lineHeight?: number
12
+ bodyColor?: string
13
+ highlightColor?: string
14
+ fontSize?: number
15
+ }
16
+
17
+ function escapeXml(s: string): string {
18
+ return s
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ }
24
+
25
+ function sliceSpans(spans: TextSpan[], start: number, end: number): TextSpan[] {
26
+ const out: TextSpan[] = []
27
+ let cursor = 0
28
+ for (const span of spans) {
29
+ const spanStart = cursor
30
+ const spanEnd = cursor + span.text.length
31
+ cursor = spanEnd
32
+ if (spanEnd <= start || spanStart >= end) continue
33
+ const sliceStart = Math.max(0, start - spanStart)
34
+ const sliceEnd = span.text.length - Math.max(0, spanEnd - end)
35
+ const txt = span.text.slice(sliceStart, sliceEnd)
36
+ if (txt.length === 0) continue
37
+ out.push(span.highlight ? { text: txt, highlight: true } : { text: txt })
38
+ }
39
+ return out
40
+ }
41
+
42
+ export function spanParagraph(
43
+ props: SpanParagraphProps,
44
+ ): { svg: string; lineCount: number } {
45
+ const chars = props.charsPerLine ?? 36
46
+ const lh = props.lineHeight ?? 14
47
+ const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
48
+ const highlightColor = props.highlightColor ?? tokens.colors.text.accent
49
+ const size = props.fontSize ?? tokens.typography.sizes.body
50
+
51
+ const plain = props.spans.map((s) => s.text).join('')
52
+ const lines = wrapText({ value: plain, charsPerLine: chars })
53
+
54
+ let charOffset = 0
55
+ const out = lines
56
+ .map((line, i) => {
57
+ const lineStart = charOffset
58
+ const lineEnd = lineStart + line.length
59
+ charOffset = lineEnd + 1
60
+ const lineSpans = sliceSpans(props.spans, lineStart, lineEnd)
61
+ const y = props.y + i * lh
62
+ const tspans = lineSpans
63
+ .map((s) => {
64
+ const fill = s.highlight ? highlightColor : bodyColor
65
+ return `<tspan fill="${fill}">${escapeXml(s.text)}</tspan>`
66
+ })
67
+ .join('')
68
+ return `<text x="${props.x}" y="${y}" font-family="${escapeAttr(tokens.typography.sans)}" font-size="${size}">${tspans}</text>`
69
+ })
70
+ .join('')
71
+ return { svg: out, lineCount: lines.length }
72
+ }
@@ -0,0 +1,85 @@
1
+ import { el } from './svg.ts'
2
+ import { text } from './text.ts'
3
+ import { tokens } from '../tokens/index.ts'
4
+
5
+ export interface StatBarProps {
6
+ x: number
7
+ y: number
8
+ width: number
9
+ label: string
10
+ abbreviation: string
11
+ value: number | null // 0..1023, or null for ranges mode (no value text, no fill)
12
+ color: string
13
+ inverted?: boolean
14
+ }
15
+
16
+ export function statBar({
17
+ x,
18
+ y,
19
+ width,
20
+ label,
21
+ abbreviation,
22
+ value,
23
+ color,
24
+ inverted,
25
+ }: StatBarProps): string {
26
+ const h = tokens.spacing.statBarHeight
27
+
28
+ let labelOut =
29
+ text({
30
+ x,
31
+ y: y - 6,
32
+ value: abbreviation,
33
+ size: tokens.typography.sizes.label,
34
+ weight: 700,
35
+ family: tokens.typography.mono,
36
+ color,
37
+ }) +
38
+ text({
39
+ x: x + 22,
40
+ y: y - 6,
41
+ value: label,
42
+ size: tokens.typography.sizes.stat,
43
+ weight: 400,
44
+ color: tokens.colors.text.primary,
45
+ })
46
+
47
+ const track = el('rect', {
48
+ x,
49
+ y,
50
+ width,
51
+ height: h,
52
+ rx: h / 2,
53
+ ry: h / 2,
54
+ fill: tokens.colors.surface.panelBorder,
55
+ })
56
+
57
+ if (value !== null) {
58
+ const clamped = Math.max(0, Math.min(1023, value))
59
+ const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
+ const filled = Math.floor(width * displayFraction)
61
+
62
+ labelOut += text({
63
+ x: x + width,
64
+ y: y - 6,
65
+ value: String(clamped),
66
+ size: tokens.typography.sizes.statValue,
67
+ weight: 700,
68
+ color,
69
+ anchor: 'end',
70
+ })
71
+
72
+ const bar = el('rect', {
73
+ x,
74
+ y,
75
+ width: filled,
76
+ height: h,
77
+ rx: h / 2,
78
+ ry: h / 2,
79
+ fill: color,
80
+ })
81
+ return labelOut + track + bar
82
+ }
83
+
84
+ return labelOut + track
85
+ }
@@ -0,0 +1,25 @@
1
+ export function escapeXml(input: string): string {
2
+ return input
3
+ .replaceAll('&', '&amp;')
4
+ .replaceAll('<', '&lt;')
5
+ .replaceAll('>', '&gt;')
6
+ .replaceAll('"', '&quot;')
7
+ .replaceAll("'", '&apos;')
8
+ }
9
+
10
+ export type AttrValue = string | number | null | undefined
11
+
12
+ export function attr(attrs: Record<string, AttrValue>): string {
13
+ let out = ''
14
+ for (const [k, v] of Object.entries(attrs)) {
15
+ if (v === undefined || v === null) continue
16
+ const value = typeof v === 'number' ? String(v) : escapeXml(v)
17
+ out += ` ${k}="${value}"`
18
+ }
19
+ return out
20
+ }
21
+
22
+ export function el(tag: string, attrs: Record<string, AttrValue>, children?: string): string {
23
+ if (children === undefined) return `<${tag}${attr(attrs)}/>`
24
+ return `<${tag}${attr(attrs)}>${children}</${tag}>`
25
+ }
@@ -0,0 +1,42 @@
1
+ import { el } from './svg.ts'
2
+ import { tokens } from '../tokens/index.ts'
3
+
4
+ export interface TextProps {
5
+ x: number
6
+ y: number
7
+ value: string
8
+ size?: number
9
+ weight?: 400 | 600 | 700 | 500
10
+ family?: string
11
+ color?: string
12
+ anchor?: 'start' | 'middle' | 'end'
13
+ letterSpacing?: number
14
+ dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
15
+ }
16
+
17
+ export function text(props: TextProps): string {
18
+ return el(
19
+ 'text',
20
+ {
21
+ x: props.x,
22
+ y: props.y,
23
+ 'font-family': props.family ?? tokens.typography.sans,
24
+ 'font-size': props.size ?? tokens.typography.sizes.body,
25
+ 'font-weight': props.weight ?? 400,
26
+ fill: props.color ?? tokens.colors.text.primary,
27
+ 'text-anchor': props.anchor,
28
+ 'letter-spacing': props.letterSpacing,
29
+ 'dominant-baseline': props.dominantBaseline,
30
+ },
31
+ escapeValue(props.value),
32
+ )
33
+ }
34
+
35
+ function escapeValue(v: string): string {
36
+ return v
37
+ .replaceAll('&', '&amp;')
38
+ .replaceAll('<', '&lt;')
39
+ .replaceAll('>', '&gt;')
40
+ .replaceAll('"', '&quot;')
41
+ .replaceAll("'", '&apos;')
42
+ }
@@ -0,0 +1,24 @@
1
+ export interface WrapProps {
2
+ value: string
3
+ charsPerLine: number
4
+ }
5
+
6
+ export function wrapText({ value, charsPerLine }: WrapProps): string[] {
7
+ const words = value.split(/\s+/).filter((w) => w.length > 0)
8
+ const lines: string[] = []
9
+ let current = ''
10
+ for (const word of words) {
11
+ if (current.length === 0) {
12
+ current = word
13
+ continue
14
+ }
15
+ if (current.length + 1 + word.length <= charsPerLine) {
16
+ current += ` ${word}`
17
+ } else {
18
+ lines.push(current)
19
+ current = word
20
+ }
21
+ }
22
+ if (current.length > 0) lines.push(current)
23
+ return lines
24
+ }
package/src/render.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { resolveItem, type ResolvedItem } from '@shipload/sdk'
2
+ import type { CargoItem } from './payload/codec.ts'
3
+ import { decodePayload } from './payload/codec.ts'
4
+ import { renderByType } from './templates/index.ts'
5
+ import { UnknownItemError } from './errors.ts'
6
+
7
+ export interface RenderOptions {
8
+ width?: number
9
+ theme?: 'dark' | 'light'
10
+ }
11
+
12
+ export function renderItem(
13
+ item: CargoItem,
14
+ resolved: ResolvedItem,
15
+ _opts?: RenderOptions,
16
+ ): string {
17
+ return renderByType(item, resolved)
18
+ }
19
+
20
+ export async function renderFromPayload(
21
+ payload: string,
22
+ opts?: RenderOptions,
23
+ ): Promise<{ svg: string; item: ResolvedItem }> {
24
+ const cargoItem = decodePayload(payload)
25
+ let resolved: ResolvedItem
26
+ try {
27
+ resolved = resolveItem(cargoItem.item_id, cargoItem.stats, cargoItem.modules)
28
+ } catch {
29
+ throw new UnknownItemError(Number(BigInt(cargoItem.item_id.toString())))
30
+ }
31
+ const svg = renderItem(cargoItem, resolved, opts)
32
+ return { svg, item: resolved }
33
+ }
@@ -0,0 +1,15 @@
1
+ import { tokens } from '../tokens/index.ts'
2
+
3
+ export function formatMass(n: number): string {
4
+ return n.toLocaleString('en-US')
5
+ }
6
+
7
+ export function tierBorder(tier: string): string {
8
+ const key = tier.toLowerCase() as keyof typeof tokens.colors.tier
9
+ return tokens.colors.tier[key] ?? tokens.colors.surface.panelBorder
10
+ }
11
+
12
+ export function shortCode(itemId: number): string {
13
+ const str = itemId.toString(10)
14
+ return str.slice(-2).padStart(2, '0')
15
+ }