Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d212ae3f30 | ||
|
|
955b0a890d | ||
|
|
b9377daa04 | ||
|
|
d6c2bb0b6a | ||
|
|
25db053a7b | ||
|
|
11b4e021fe | ||
|
|
654d3849eb | ||
|
|
3506d1f6c5 | ||
|
|
8c83e8b8e8 | ||
|
|
399db56499 |
@@ -1,6 +1,7 @@
|
|||||||
name: Build & Push
|
name: Build & Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -33,18 +34,44 @@ jobs:
|
|||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push app
|
- name: Extract metadata (app)
|
||||||
uses: https://git.sebse.de/sebse/actions/docker-build-push@v1
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
image: ${{ env.IMAGE }}
|
images: ${{ env.IMAGE }}
|
||||||
build-context: ./web
|
tags: |
|
||||||
|
type=sha,prefix=,format=short
|
||||||
|
type=raw,value=latest
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Build and push app
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./web
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
- name: Extract metadata (migrate)
|
||||||
|
id: meta-migrate
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_MIGRATE }}
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=,format=short
|
||||||
|
type=raw,value=latest
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
- name: Build and push migrate
|
- name: Build and push migrate
|
||||||
uses: https://git.sebse.de/sebse/actions/docker-build-push@v1
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
image: ${{ env.IMAGE_MIGRATE }}
|
context: ./web
|
||||||
build-context: ./web
|
file: ./web/Dockerfile.migrate
|
||||||
dockerfile: ./web/Dockerfile.migrate
|
push: true
|
||||||
|
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE_MIGRATE }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
- name: Package and push helm chart
|
- name: Package and push helm chart
|
||||||
uses: https://git.sebse.de/sebse/actions/helm-package-push@v1
|
uses: https://git.sebse.de/sebse/actions/helm-package-push@v1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const POST = withAuth(async (req: NextRequest) => {
|
|||||||
filter_combinators = null, filter_items = null,
|
filter_combinators = null, filter_items = null,
|
||||||
filter_items_exclude = null, filter_items_regex = false,
|
filter_items_exclude = null, filter_items_regex = false,
|
||||||
y_min = null, y_max = null, y_scale = 'linear',
|
y_min = null, y_max = null, y_scale = 'linear',
|
||||||
series_limit = 20, order_by = 'time',
|
series_limit = 20, order_by = 'value_asc',
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
|
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
const from = p.get('from');
|
const from = p.get('from');
|
||||||
const to = p.get('to');
|
const to = p.get('to');
|
||||||
const useRegex = p.get('regex') === 'true';
|
const useRegex = p.get('regex') === 'true';
|
||||||
const orderBy = p.get('order_by') ?? 'time';
|
const orderBy = p.get('order_by') ?? 'value_asc';
|
||||||
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
|
const limit = p.get('limit') ? parseInt(p.get('limit')!, 10) : null;
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
@@ -28,12 +28,15 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
if (itemsWhitelist.length > 0) {
|
if (itemsWhitelist.length > 0) {
|
||||||
if (useRegex) {
|
if (useRegex) {
|
||||||
const localeMap = getServerLocaleMap();
|
const localeMap = getServerLocaleMap();
|
||||||
// Each pattern is expanded to matching keys (tested against key AND localized name).
|
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
|
||||||
// Union all patterns — if a pattern matches nothing, it contributes no keys.
|
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|');
|
||||||
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
|
const orConds = [`item_key ~* $${i++}`];
|
||||||
if (keys.length === 0) return NextResponse.json([]);
|
values.push(sqlPattern);
|
||||||
conditions.push(`item_key = ANY($${i++})`);
|
if (localeKeys.length > 0) {
|
||||||
values.push(keys);
|
orConds.push(`item_key = ANY($${i++})`);
|
||||||
|
values.push(localeKeys);
|
||||||
|
}
|
||||||
|
conditions.push(`(${orConds.join(' OR ')})`);
|
||||||
} else {
|
} else {
|
||||||
conditions.push(`item_key = ANY($${i++})`);
|
conditions.push(`item_key = ANY($${i++})`);
|
||||||
values.push(itemsWhitelist);
|
values.push(itemsWhitelist);
|
||||||
@@ -43,12 +46,15 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
if (itemsBlacklist.length > 0) {
|
if (itemsBlacklist.length > 0) {
|
||||||
if (useRegex) {
|
if (useRegex) {
|
||||||
const localeMap = getServerLocaleMap();
|
const localeMap = getServerLocaleMap();
|
||||||
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
|
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
|
||||||
// If blacklist pattern matches nothing, nothing to exclude — skip condition
|
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|');
|
||||||
if (keys.length > 0) {
|
const andConds = [`item_key !~* $${i++}`];
|
||||||
conditions.push(`item_key != ALL($${i++})`);
|
values.push(sqlPattern);
|
||||||
values.push(keys);
|
if (localeKeys.length > 0) {
|
||||||
|
andConds.push(`item_key != ALL($${i++})`);
|
||||||
|
values.push(localeKeys);
|
||||||
}
|
}
|
||||||
|
conditions.push(`(${andConds.join(' AND ')})`);
|
||||||
} else {
|
} else {
|
||||||
conditions.push(`item_key != ALL($${i++})`);
|
conditions.push(`item_key != ALL($${i++})`);
|
||||||
values.push(itemsBlacklist);
|
values.push(itemsBlacklist);
|
||||||
@@ -78,10 +84,15 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
if (itemsWhitelist.length > 0) {
|
if (itemsWhitelist.length > 0) {
|
||||||
if (useRegex) {
|
if (useRegex) {
|
||||||
const localeMap = getServerLocaleMap();
|
const localeMap = getServerLocaleMap();
|
||||||
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
|
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
|
||||||
if (keys.length === 0) return NextResponse.json([]);
|
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|');
|
||||||
baseConditions.push(`item_key = ANY($${j++})`);
|
const orConds = [`item_key ~* $${j++}`];
|
||||||
baseValues.push(keys);
|
baseValues.push(sqlPattern);
|
||||||
|
if (localeKeys.length > 0) {
|
||||||
|
orConds.push(`item_key = ANY($${j++})`);
|
||||||
|
baseValues.push(localeKeys);
|
||||||
|
}
|
||||||
|
baseConditions.push(`(${orConds.join(' OR ')})`);
|
||||||
} else {
|
} else {
|
||||||
baseConditions.push(`item_key = ANY($${j++})`);
|
baseConditions.push(`item_key = ANY($${j++})`);
|
||||||
baseValues.push(itemsWhitelist);
|
baseValues.push(itemsWhitelist);
|
||||||
@@ -90,11 +101,15 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
if (itemsBlacklist.length > 0) {
|
if (itemsBlacklist.length > 0) {
|
||||||
if (useRegex) {
|
if (useRegex) {
|
||||||
const localeMap = getServerLocaleMap();
|
const localeMap = getServerLocaleMap();
|
||||||
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
|
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
|
||||||
if (keys.length > 0) {
|
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|');
|
||||||
baseConditions.push(`item_key != ALL($${j++})`);
|
const andConds = [`item_key !~* $${j++}`];
|
||||||
baseValues.push(keys);
|
baseValues.push(sqlPattern);
|
||||||
|
if (localeKeys.length > 0) {
|
||||||
|
andConds.push(`item_key != ALL($${j++})`);
|
||||||
|
baseValues.push(localeKeys);
|
||||||
}
|
}
|
||||||
|
baseConditions.push(`(${andConds.join(' AND ')})`);
|
||||||
} else {
|
} else {
|
||||||
baseConditions.push(`item_key != ALL($${j++})`);
|
baseConditions.push(`item_key != ALL($${j++})`);
|
||||||
baseValues.push(itemsBlacklist);
|
baseValues.push(itemsBlacklist);
|
||||||
@@ -147,8 +162,11 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
||||||
);
|
);
|
||||||
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
|
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
|
||||||
|
const orderCase = top.map((_, idx) =>
|
||||||
|
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
|
||||||
|
).join(' ');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
|
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
||||||
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows);
|
return NextResponse.json(result.rows);
|
||||||
@@ -175,8 +193,11 @@ export const GET = withAuth(async (req: NextRequest) => {
|
|||||||
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
`(combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1})`,
|
||||||
);
|
);
|
||||||
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
|
const fullWhere = `${where ? where + ' AND' : 'WHERE'} (${seriesConditions.join(' OR ')})`;
|
||||||
|
const orderCase = top.map((_, idx) =>
|
||||||
|
`WHEN combinator = $${i + idx * 2} AND item_key = $${i + idx * 2 + 1} THEN ${idx}`,
|
||||||
|
).join(' ');
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY real_time ASC`,
|
`SELECT ${selectCols} FROM signals ${fullWhere} ORDER BY CASE ${orderCase} ELSE ${top.length} END, real_time ASC`,
|
||||||
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
[...values, ...top.flatMap(r => [r.combinator, r.item_key])],
|
||||||
);
|
);
|
||||||
return NextResponse.json(result.rows);
|
return NextResponse.json(result.rows);
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
web/bin/fix-colors.ts
Normal file
87
web/bin/fix-colors.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
const CSV = 'public/factorio_item_colors.csv';
|
||||||
|
|
||||||
|
function hexToHsl(h: string): [number, number, number] {
|
||||||
|
let r = parseInt(h.slice(1, 3), 16) / 255, g = parseInt(h.slice(3, 5), 16) / 255, b = parseInt(h.slice(5, 7), 16) / 255;
|
||||||
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
|
||||||
|
if (mx === mn) return [0, 0, Math.round(l * 100)];
|
||||||
|
const d = mx - mn, s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||||
|
let hue = 0;
|
||||||
|
if (mx === r) hue = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||||
|
else if (mx === g) hue = ((b - r) / d + 2) * 60;
|
||||||
|
else hue = ((r - g) / d + 4) * 60;
|
||||||
|
return [Math.round(hue), Math.round(s * 100), Math.round(l * 100)];
|
||||||
|
}
|
||||||
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
s /= 100; l /= 100;
|
||||||
|
const c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; }
|
||||||
|
else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; }
|
||||||
|
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
|
||||||
|
return '#' + to(r) + to(g) + to(b);
|
||||||
|
}
|
||||||
|
function djb2(s: string): number {
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
function hueDist(a: number, b: number): number {
|
||||||
|
const d = Math.abs(a - b);
|
||||||
|
return Math.min(d, 360 - d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = readFileSync(CSV, 'utf-8').trim().split('\n');
|
||||||
|
const header = lines[0];
|
||||||
|
const raw = lines.slice(1).filter(l => l.trim()).map(l => {
|
||||||
|
const [k, c] = l.split(',');
|
||||||
|
return { key: k.trim(), color: c.trim() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dedup by key
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rows: typeof raw = [];
|
||||||
|
for (const r of raw) { if (seen.has(r.key)) continue; seen.add(r.key); rows.push(r); }
|
||||||
|
|
||||||
|
const n = rows.length;
|
||||||
|
const parent = Array.from({ length: n }, (_, i) => i);
|
||||||
|
function find(x: number): number {
|
||||||
|
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function union(a: number, b: number) { parent[find(a)] = find(b); }
|
||||||
|
|
||||||
|
const hsl = rows.map((r, i) => ({ ...r, hsl: hexToHsl(r.color), idx: i }));
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
if (hueDist(hsl[i].hsl[0], hsl[j].hsl[0]) <= 0.5 &&
|
||||||
|
Math.abs(hsl[i].hsl[2] - hsl[j].hsl[2]) <= 0.5)
|
||||||
|
union(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<number, typeof hsl>();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const root = find(i);
|
||||||
|
if (!groups.has(root)) groups.set(root, []);
|
||||||
|
groups.get(root)!.push(hsl[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
for (const [, items] of groups) {
|
||||||
|
if (items.length < 2) continue;
|
||||||
|
items.sort((a, b) => a.idx - b.idx);
|
||||||
|
const [oh, os, ol] = items[0].hsl;
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const hash = djb2(items[i].key);
|
||||||
|
const h = (oh + (hash % 5 - 2) + i * 7 + 360) % 360;
|
||||||
|
const l = Math.max(0, Math.min(100, ol + ((hash >> 4) % 5 - 2)));
|
||||||
|
items[i].color = hslToHex(h, os, l);
|
||||||
|
fixed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hsl.sort((a, b) => a.idx - b.idx);
|
||||||
|
writeFileSync(CSV, header + '\n' + hsl.map(r => `${r.key},${r.color}`).join('\n') + '\n');
|
||||||
|
if (fixed) console.log(`Fixed ${fixed} close colors`);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,22 +32,55 @@ interface CardShellProps extends HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
|
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [legendHeight, setLegendHeight] = useState<number | null>(null);
|
||||||
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = legendContainerRef?.current;
|
||||||
|
if (!el) return;
|
||||||
|
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const delta = dragRef.current.startY - ev.clientY;
|
||||||
|
const containerH = containerRef.current?.offsetHeight ?? 400;
|
||||||
|
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
|
||||||
|
setLegendHeight(newH);
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
dragRef.current = null;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}, [legendContainerRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
|
<div ref={containerRef} className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
|
||||||
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Plot fills remaining space */}
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{/* Legend scrolls independently, capped at 25% card height */}
|
{legendContainerRef && <>
|
||||||
<div
|
<div
|
||||||
ref={legendContainerRef}
|
onMouseDown={handleMouseDown}
|
||||||
className="shrink-0 max-h-[25%] overflow-y-auto border-t border-gray-800 px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
ref={legendContainerRef}
|
||||||
|
className="shrink-0 overflow-y-auto px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
||||||
|
style={legendHeight != null ? { height: legendHeight, maxHeight: legendHeight } : { maxHeight: '25%' }}
|
||||||
|
/>
|
||||||
|
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
|
||||||
|
</>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import 'uplot/dist/uPlot.min.css';
|
import 'uplot/dist/uPlot.min.css';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useApp } from '@/lib/context';
|
import { useApp } from '@/lib/context';
|
||||||
import { resolveName } from '@/lib/localization';
|
import { resolveName } from '@/lib/localization';
|
||||||
|
import { getColorMap } from '@/lib/colors';
|
||||||
|
import type { ColorMap } from '@/lib/colors';
|
||||||
import { CardShell } from './CardShell';
|
import { CardShell } from './CardShell';
|
||||||
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
|
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
|
||||||
import { buildSeriesData } from './seriesData';
|
import { buildSeriesData } from './seriesData';
|
||||||
@@ -23,6 +26,9 @@ interface Props {
|
|||||||
|
|
||||||
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
|
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
|
||||||
const { localeMap } = useApp();
|
const { localeMap } = useApp();
|
||||||
|
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
|
||||||
|
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||||
|
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
||||||
|
|
||||||
const { containerRef, legendRef } = usePlot(
|
const { containerRef, legendRef } = usePlot(
|
||||||
(el, w, h, lRef) => {
|
(el, w, h, lRef) => {
|
||||||
@@ -44,8 +50,8 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
|
|||||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
|
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap),
|
||||||
axes: makeSignalsAxes(timeMode),
|
axes: makeSignalsAxes(timeMode, locale),
|
||||||
scales: {
|
scales: {
|
||||||
x: { time: false },
|
x: { time: false },
|
||||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useApp } from '@/lib/context';
|
import { useApp } from '@/lib/context';
|
||||||
import { resolveName } from '@/lib/localization';
|
import { resolveName } from '@/lib/localization';
|
||||||
|
import { formatSI } from '@/lib/formatNumber';
|
||||||
|
import { getColorMap, getItemColor } from '@/lib/colors';
|
||||||
|
import type { ColorMap } from '@/lib/colors';
|
||||||
import { CardShell } from './CardShell';
|
import { CardShell } from './CardShell';
|
||||||
import type { ChartConfig, SignalRow } from '@/lib/types';
|
import type { ChartConfig, SignalRow } from '@/lib/types';
|
||||||
|
|
||||||
@@ -12,12 +16,14 @@ interface Props {
|
|||||||
|
|
||||||
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||||
const { localeMap } = useApp();
|
const { localeMap } = useApp();
|
||||||
|
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
|
||||||
|
useEffect(() => { getColorMap().then(setColorMap); }, []);
|
||||||
|
|
||||||
const latest = new Map<string, { green?: number; red?: number }>();
|
const latest = new Map<string, { green?: number; red?: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red });
|
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red });
|
||||||
}
|
}
|
||||||
const tableRows = [...latest.entries()].sort((a, b) => (a[1].green ?? 0) - (b[1].green ?? 0));
|
const tableRows = [...latest.entries()].slice(0, config.series_limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
|
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
|
||||||
@@ -36,16 +42,20 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
|||||||
const [combinator, item_key] = key.split('::');
|
const [combinator, item_key] = key.split('::');
|
||||||
return (
|
return (
|
||||||
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
||||||
<td className="px-2 py-0.5">{resolveName(item_key, localeMap)}</td>
|
<td className="px-2 py-0.5 flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: getItemColor(item_key, colorMap) }} />
|
||||||
|
{resolveName(item_key, localeMap)}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
||||||
{config.signal_type !== 'red' && (
|
{config.signal_type !== 'red' && (
|
||||||
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
|
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
{vals.green?.toLocaleString() ?? '--'}
|
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{config.signal_type !== 'green' && (
|
{config.signal_type !== 'green' && (
|
||||||
<td className="px-2 py-0.5 text-right font-mono text-orange-400">
|
<td className="px-2 py-0.5 text-right font-mono text-orange-400">
|
||||||
{vals.red?.toLocaleString() ?? '--'}
|
{vals.red != null ? formatSI(vals.red, undefined, 0) : '--'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css';
|
|||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import { CardShell } from './CardShell';
|
import { CardShell } from './CardShell';
|
||||||
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
|
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
|
||||||
|
import { formatSI } from '@/lib/formatNumber';
|
||||||
import { usePlot } from './usePlot';
|
import { usePlot } from './usePlot';
|
||||||
import type { ChartConfig, UpsRow } from '@/lib/types';
|
import type { ChartConfig, UpsRow } from '@/lib/types';
|
||||||
import type { TimeMode } from '@/lib/types';
|
import type { TimeMode } from '@/lib/types';
|
||||||
@@ -31,9 +32,9 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
|
|||||||
|
|
||||||
const xAxis: uPlot.Axis = {
|
const xAxis: uPlot.Axis = {
|
||||||
...AXIS_BASE,
|
...AXIS_BASE,
|
||||||
...(timeMode === 'real' && {
|
values: timeMode === 'real'
|
||||||
values: (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
|
? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
||||||
}),
|
: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new uPlot({
|
return new uPlot({
|
||||||
@@ -49,7 +50,7 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
|
|||||||
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
||||||
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
||||||
],
|
],
|
||||||
axes: [xAxis, { ...AXIS_BASE }],
|
axes: [xAxis, { ...AXIS_BASE, values: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)) }],
|
||||||
scales: {
|
scales: {
|
||||||
x: { time: false },
|
x: { time: false },
|
||||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
import type { ChartConfig } from '@/lib/types';
|
import type { ChartConfig } from '@/lib/types';
|
||||||
|
import { formatSI } from '@/lib/formatNumber';
|
||||||
// --- Color helpers ---
|
import type { ColorMap } from '@/lib/colors';
|
||||||
|
import { getItemColor } from '@/lib/colors';
|
||||||
function djb2(s: string): number {
|
|
||||||
let h = 5381;
|
|
||||||
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
|
|
||||||
return h >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hslColor(key: string): string {
|
|
||||||
const hue = djb2(key) % 360;
|
|
||||||
return `hsl(${hue},70%,60%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEMANTIC_GREEN = '#4ade80';
|
const SEMANTIC_GREEN = '#4ade80';
|
||||||
const SEMANTIC_RED = '#f87171';
|
const SEMANTIC_RED = '#f87171';
|
||||||
@@ -23,10 +13,11 @@ export interface SeriesStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSeriesStyle(
|
export function getSeriesStyle(
|
||||||
key: string,
|
key: string,
|
||||||
uCombs: number,
|
uCombs: number,
|
||||||
uItems: number,
|
uItems: number,
|
||||||
uSigs: number,
|
uSigs: number,
|
||||||
|
colorMap: ColorMap = new Map(),
|
||||||
): SeriesStyle {
|
): SeriesStyle {
|
||||||
const [combinator, item_key, sig] = key.split('::');
|
const [combinator, item_key, sig] = key.split('::');
|
||||||
|
|
||||||
@@ -34,9 +25,9 @@ export function getSeriesStyle(
|
|||||||
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
||||||
}
|
}
|
||||||
if (uItems > 1) {
|
if (uItems > 1) {
|
||||||
return { color: hslColor(item_key), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
return { color: getItemColor(item_key, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||||
}
|
}
|
||||||
return { color: hslColor(combinator), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
return { color: getItemColor(combinator, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +127,7 @@ export function makeSignalsSeries(
|
|||||||
keys: string[],
|
keys: string[],
|
||||||
timeMode: 'real' | 'tick',
|
timeMode: 'real' | 'tick',
|
||||||
resolveName: (key: string) => string,
|
resolveName: (key: string) => string,
|
||||||
|
colorMap: ColorMap = new Map(),
|
||||||
): uPlot.Series[] {
|
): uPlot.Series[] {
|
||||||
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
|
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
|
||||||
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
|
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
|
||||||
@@ -153,7 +145,7 @@ export function makeSignalsSeries(
|
|||||||
xSeries,
|
xSeries,
|
||||||
...keys.map(k => {
|
...keys.map(k => {
|
||||||
const [, item_key] = k.split('::');
|
const [, item_key] = k.split('::');
|
||||||
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs);
|
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
|
||||||
return {
|
return {
|
||||||
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
||||||
stroke: color,
|
stroke: color,
|
||||||
@@ -164,15 +156,20 @@ export function makeSignalsSeries(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] {
|
export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPlot.Axis[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...AXIS_BASE,
|
...AXIS_BASE,
|
||||||
...(timeMode === 'real' && {
|
values: timeMode === 'real'
|
||||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
? (_u: uPlot, vals: (number | null)[]) =>
|
||||||
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
|
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
|
||||||
}),
|
: (_u: uPlot, vals: (number | null)[]) =>
|
||||||
|
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...AXIS_BASE,
|
||||||
|
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||||
|
vals.map(v => v == null ? '' : formatSI(v, locale)),
|
||||||
},
|
},
|
||||||
{ ...AXIS_BASE },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
|||||||
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
|
||||||
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
|
||||||
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
const [useRegex, setUseRegex] = useState(initial?.filter_items_regex ?? false);
|
||||||
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'time');
|
const [orderBy, setOrderBy] = useState<ChartConfig['order_by']>(initial?.order_by ?? 'value_asc');
|
||||||
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
const [seriesLimit, setSeriesLimit] = useState(initial?.series_limit ?? 20);
|
||||||
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
const [yMin, setYMin] = useState(initial?.y_min?.toString() ?? '');
|
||||||
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
const [yMax, setYMax] = useState(initial?.y_max?.toString() ?? '');
|
||||||
@@ -123,7 +123,6 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
|
|||||||
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
<label className="block text-sm text-gray-400 mb-1">Sort series by</label>
|
||||||
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
|
<select value={orderBy} onChange={e => setOrderBy(e.target.value as ChartConfig['order_by'])} className={`${inputCls} mb-3`}>
|
||||||
<option value="time">Time (no sort)</option>
|
|
||||||
<option value="value_asc">Latest lowest values</option>
|
<option value="value_asc">Latest lowest values</option>
|
||||||
<option value="value_desc">Latest highest values</option>
|
<option value="value_desc">Latest highest values</option>
|
||||||
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
<option value="delta_asc">Biggest decrease (last 10 min)</option>
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ export default function Dashboard({ alerts }: Props) {
|
|||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
|
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
|
||||||
dragConfig={{ handle: '.drag-handle' }}
|
dragConfig={{ handle: '.drag-handle' }}
|
||||||
|
resizeConfig={{
|
||||||
|
handleComponent: (axis, ref) => (
|
||||||
|
<span ref={ref}
|
||||||
|
className="react-resizable-handle react-resizable-handle-se"
|
||||||
|
style={{
|
||||||
|
borderRight: '3px solid #4b5563',
|
||||||
|
borderBottom: '3px solid #4b5563',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{charts.map(c => (
|
{charts.map(c => (
|
||||||
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">
|
||||||
|
|||||||
35
web/lib/colors.ts
Normal file
35
web/lib/colors.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type ColorMap = Map<string, string>;
|
||||||
|
|
||||||
|
declare global { var __colorMapCache: ColorMap | undefined; }
|
||||||
|
|
||||||
|
export function parseColorCsv(text: string): ColorMap {
|
||||||
|
const map: ColorMap = new Map();
|
||||||
|
const lines = text.split(/\r?\n/).slice(1);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const [key, color] = line.split(',');
|
||||||
|
if (key && color) map.set(key.trim(), color.trim());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getColorMap(): Promise<ColorMap> {
|
||||||
|
if (globalThis.__colorMapCache) return globalThis.__colorMapCache;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/factorio_item_colors.csv');
|
||||||
|
globalThis.__colorMapCache = res.ok ? parseColorCsv(await res.text()) : new Map();
|
||||||
|
} catch {
|
||||||
|
globalThis.__colorMapCache = new Map();
|
||||||
|
}
|
||||||
|
return globalThis.__colorMapCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function djb2(s: string): number {
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemColor(key: string, colorMap: ColorMap): string {
|
||||||
|
return colorMap.get(key) ?? `hsl(${djb2(key) % 360},70%,60%)`;
|
||||||
|
}
|
||||||
23
web/lib/formatNumber.ts
Normal file
23
web/lib/formatNumber.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const SI_THRESHOLDS = [
|
||||||
|
{ limit: 1_000_000_000, divisor: 1_000_000_000, suffix: 'G' },
|
||||||
|
{ limit: 1_000_000, divisor: 1_000_000, suffix: 'M' },
|
||||||
|
{ limit: 1_000, divisor: 1_000, suffix: 'K' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function formatSI(v: number, locale?: string, fractionDigits?: number): string {
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
const fd = fractionDigits ?? 3;
|
||||||
|
for (const { limit, divisor, suffix } of SI_THRESHOLDS) {
|
||||||
|
if (abs >= limit) {
|
||||||
|
const formatted = new Intl.NumberFormat(locale, {
|
||||||
|
maximumFractionDigits: fd,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(v / divisor);
|
||||||
|
return `${formatted}${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
maximumFractionDigits: fractionDigits != null ? fractionDigits : 0,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(v);
|
||||||
|
}
|
||||||
5
web/package-lock.json
generated
5
web/package-lock.json
generated
@@ -986,6 +986,7 @@
|
|||||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"pg-protocol": "*",
|
"pg-protocol": "*",
|
||||||
@@ -998,6 +999,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1795,6 +1797,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@@ -1968,6 +1971,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -1977,6 +1981,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|||||||
379
web/public/factorio_item_colors.csv
Normal file
379
web/public/factorio_item_colors.csv
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
item_key,color
|
||||||
|
copper-ore,#bf8040
|
||||||
|
copper-plate,#b87333
|
||||||
|
copper-cable,#e65100
|
||||||
|
iron-ore,#8d6e63
|
||||||
|
iron-plate,#bdbdbd
|
||||||
|
iron-gear-wheel,#a0a0a0
|
||||||
|
iron-stick,#888888
|
||||||
|
steel-plate,#757575
|
||||||
|
steel-gear-wheel,#5a5a5a
|
||||||
|
steel-beam,#424242
|
||||||
|
stone,#a1887f
|
||||||
|
stone-brick,#b8956a
|
||||||
|
coal,#37474f
|
||||||
|
uranium-ore,#66bb6a
|
||||||
|
uranium-238,#7cb342
|
||||||
|
uranium-235,#64dd17
|
||||||
|
crude-oil,#1a1a1a
|
||||||
|
heavy-oil,#4e342e
|
||||||
|
light-oil,#ff8a65
|
||||||
|
petroleum-gas,#1a0030
|
||||||
|
lubricant,#1b5e20
|
||||||
|
sulfuric-acid,#b2dfdb
|
||||||
|
water,#1565c0
|
||||||
|
steam,#e1f5fe
|
||||||
|
wood,#6d4c41
|
||||||
|
raw-wood,#3e2723
|
||||||
|
plastic-bar,#b0bec5
|
||||||
|
sulfur,#fff176
|
||||||
|
explosives,#ef5350
|
||||||
|
battery,#ff9800
|
||||||
|
empty-barrel,#90a4ae
|
||||||
|
filled-barrel,#546e7a
|
||||||
|
electronic-circuit,#4caf50
|
||||||
|
advanced-circuit,#e53935
|
||||||
|
processing-unit,#1565d2
|
||||||
|
automation-science-pack,#d32f2f
|
||||||
|
logistic-science-pack,#43a047
|
||||||
|
military-science-pack,#616161
|
||||||
|
chemical-science-pack,#00acc1
|
||||||
|
production-science-pack,#ff8f00
|
||||||
|
utility-science-pack,#7b1fa2
|
||||||
|
space-science-pack,#f48fb1
|
||||||
|
pipe,#78909c
|
||||||
|
engine-unit,#795548
|
||||||
|
electric-engine-unit,#4fc3f7
|
||||||
|
flying-robot-frame,#5e35b1
|
||||||
|
rocket-fuel,#ff5722
|
||||||
|
rocket-control-unit,#2e7d32
|
||||||
|
low-density-structure,#bcaaa4
|
||||||
|
heat-pipe,#bf360c
|
||||||
|
heat-exchanger,#263238
|
||||||
|
steam-turbine,#455a64
|
||||||
|
concrete,#9e9e9e
|
||||||
|
refined-concrete,#6d6d6d
|
||||||
|
landfill,#388e3c
|
||||||
|
cliff-explosives,#d84315
|
||||||
|
nuclear-fuel,#00c853
|
||||||
|
solid-fuel,#0d47a1
|
||||||
|
grenade,#5d4037
|
||||||
|
cluster-grenade,#c62828
|
||||||
|
landmine,#4a148c
|
||||||
|
fish,#29b6f6
|
||||||
|
glass,#4dd0e1
|
||||||
|
rail,#9e9e9e
|
||||||
|
rail-signal,#449c53
|
||||||
|
rail-chain-signal,#e33d2b
|
||||||
|
train-stop,#323f48
|
||||||
|
locomotive,#ce3c2c
|
||||||
|
cargo-wagon,#937966
|
||||||
|
fluid-wagon,#1450b8
|
||||||
|
artillery-wagon,#3d3d3d
|
||||||
|
artillery-turret,#383838
|
||||||
|
flamethrower-turret,#cc4e14
|
||||||
|
gun-turret,#5c5c5c
|
||||||
|
laser-turret,#e45d2f
|
||||||
|
radar,#378b45
|
||||||
|
roboport,#2196f3
|
||||||
|
construction-robot,#449c5f
|
||||||
|
logistic-robot,#133baa
|
||||||
|
speed-module,#f44336
|
||||||
|
speed-module-2,#e91e63
|
||||||
|
speed-module-3,#9c27b0
|
||||||
|
speed-module-5,#4c1178
|
||||||
|
effectivity-module,#4cae70
|
||||||
|
effectivity-module-2,#3a9256
|
||||||
|
effectivity-module-3,#30823f
|
||||||
|
effectivity-module-4,#1d6327
|
||||||
|
productivity-module,#ffb005
|
||||||
|
productivity-module-2,#ef6c00
|
||||||
|
productivity-module-3,#eb6600
|
||||||
|
productivity-module-5,#c5500d
|
||||||
|
beacon,#2078f3
|
||||||
|
substation,#b427b4
|
||||||
|
medium-electric-pole,#687d8d
|
||||||
|
big-electric-pole,#566c7b
|
||||||
|
small-electric-pole,#8c9eab
|
||||||
|
small-iron-electric-pole,#8996a9
|
||||||
|
steel-chest,#787878
|
||||||
|
iron-chest,#907c64
|
||||||
|
wooden-chest,#634b3b
|
||||||
|
transport-belt,#b3b3b3
|
||||||
|
fast-transport-belt,#50b1f7
|
||||||
|
express-transport-belt,#e56f34
|
||||||
|
underground-belt,#bfbfbf
|
||||||
|
fast-underground-belt,#4193f6
|
||||||
|
express-underground-belt,#e48d2f
|
||||||
|
splitter,#bdbdbd
|
||||||
|
fast-splitter,#3277f5
|
||||||
|
express-splitter,#dd8a1d
|
||||||
|
long-handed-inserter,#80604d
|
||||||
|
fast-inserter,#80664d
|
||||||
|
burner-inserter,#7c694b
|
||||||
|
inserter,#7c704b
|
||||||
|
stack-inserter,#47322a
|
||||||
|
burner-mining-drill,#706943
|
||||||
|
electric-mining-drill,#737345
|
||||||
|
area-mining-drill,#7c804d
|
||||||
|
electric-furnace,#384751
|
||||||
|
industrial-furnace,#475666
|
||||||
|
assembling-machine-3,#666666
|
||||||
|
centrifuge,#546578
|
||||||
|
chemical-plant,#0092c7
|
||||||
|
oil-refinery,#0f0f0f
|
||||||
|
pumpjack,#433428
|
||||||
|
offshore-pump,#162eca
|
||||||
|
boiler,#9e9e9e
|
||||||
|
steam-engine,#6c7d93
|
||||||
|
solar-panel,#ffb74d
|
||||||
|
accumulator,#0c59e9
|
||||||
|
lamp,#ffeb3b
|
||||||
|
constant-combinator,#495369
|
||||||
|
decider-combinator,#4d556f
|
||||||
|
arithmetic-combinator,#585b7e
|
||||||
|
power-switch,#ffb405
|
||||||
|
programmable-speaker,#751a89
|
||||||
|
aai-signal-receiver,#323c48
|
||||||
|
aai-signal-transmitter,#2a2f3c
|
||||||
|
textplate-small-copper,#e68600
|
||||||
|
used-up-uranium-fuel-cell,#34384b
|
||||||
|
uranium-fuel-cell,#5db669
|
||||||
|
electric-motor,#5976f7
|
||||||
|
motor,#4652f6
|
||||||
|
automation-core,#1712a5
|
||||||
|
iron-beam,#878787
|
||||||
|
kr-advanced-solar-panel,#ffc23d
|
||||||
|
kr-advanced-transport-belt,#26c6da
|
||||||
|
kr-advanced-loader,#0070a3
|
||||||
|
kr-advanced-splitter,#00bcd4
|
||||||
|
kr-superior-inserter,#74804d
|
||||||
|
kr-superior-filter-inserter,#5d4437
|
||||||
|
kr-superior-long-inserter,#6f804d
|
||||||
|
kr-superior-long-filter-inserter,#5d4737
|
||||||
|
kr-superior-underground-belt,#0054a8
|
||||||
|
kr-superior-loader,#0048bd
|
||||||
|
kr-fuel-refinery,#bb580c
|
||||||
|
kr-quarry-drill,#68804d
|
||||||
|
kr-express-loader,#00838f
|
||||||
|
kr-electric-mining-drill-mk2,#47382a
|
||||||
|
kr-steel-pipe,#6e6e6e
|
||||||
|
kr-steel-pipe-to-ground,#787878
|
||||||
|
kr-fluid-storage-2,#2415c1
|
||||||
|
kr-se-loader,#0039b3
|
||||||
|
beryllium,#81c784
|
||||||
|
beryllium-ore,#558b2f
|
||||||
|
beryllium-sulfate,#00695c
|
||||||
|
holmium,#ba68c8
|
||||||
|
holmium-ore,#ab47bc
|
||||||
|
holmium-solution,#e1bee7
|
||||||
|
cryonite,#00a8e0
|
||||||
|
cryonite-rod,#29aedb
|
||||||
|
cryonite-solution,#80deea
|
||||||
|
iridium,#cfd8dc
|
||||||
|
iridium-ore,#689f38
|
||||||
|
iridium-ingot,#e0e0e0
|
||||||
|
vulcanite,#ff7043
|
||||||
|
vulcanite-block,#e64a19
|
||||||
|
vulcanite-powder,#ffab91
|
||||||
|
imersite,#8e24aa
|
||||||
|
imersite-crystal,#283593
|
||||||
|
imersite-powder,#ce93d8
|
||||||
|
vita,#8bc34a
|
||||||
|
vita-extract,#19572a
|
||||||
|
vita-germination,#006064
|
||||||
|
naquium,#ffd54f
|
||||||
|
naquium-ore,#ffca28
|
||||||
|
naquium-ingot,#fff9c4
|
||||||
|
methane-ice,#b2ebf2
|
||||||
|
core-fragment,#4db6ac
|
||||||
|
rare-metals,#b0b0b0
|
||||||
|
raw-rare-metals,#a8a8a8
|
||||||
|
raw-imersite,#821e8f
|
||||||
|
sand,#c2b280
|
||||||
|
silicon,#6c7893
|
||||||
|
quartz,#e6e6e6
|
||||||
|
coke,#474747
|
||||||
|
se-copper-ingot,#b08130
|
||||||
|
se-iron-ingot,#84755c
|
||||||
|
se-steel-ingot,#707070
|
||||||
|
se-beryllium-ingot,#87c995
|
||||||
|
se-holmium-ingot,#c056c2
|
||||||
|
se-iridium-ingot,#d6dce1
|
||||||
|
se-cryonite-rod,#2397d1
|
||||||
|
se-cryonite-slush,#7bccea
|
||||||
|
se-vulcanite-crushed,#ff7b29
|
||||||
|
se-vulcanite-enriched,#ff6d29
|
||||||
|
se-vulcanite-block,#dc5a18
|
||||||
|
imersium-plate,#8e1d9a
|
||||||
|
se-kr-imersium-sulfide,#9f1e96
|
||||||
|
se-kr-fine-imersite-powder,#d58bd5
|
||||||
|
enriched-iron,#7e7358
|
||||||
|
electronic-components,#4eb183
|
||||||
|
empty-data-card,#512da8
|
||||||
|
heat-shielding,#00bfa5
|
||||||
|
thermodynamic-boiler,#311b92
|
||||||
|
cryogenic-plant,#0097a7
|
||||||
|
core-drill,#3d5afe
|
||||||
|
core-miner,#1a237e
|
||||||
|
se-quantum-processor,#123db5
|
||||||
|
se-holmium-cable,#c256bb
|
||||||
|
se-holmium-solenoid,#c45aae
|
||||||
|
se-superconductive-cable,#1c176e
|
||||||
|
se-data-storage-substrate,#4527a0
|
||||||
|
se-machine-learning-data,#3949ab
|
||||||
|
se-empty-data,#5c6bc0
|
||||||
|
se-broken-data,#7986cb
|
||||||
|
se-junk-data,#9fa8da
|
||||||
|
se-scrap,#757575
|
||||||
|
se-contaminated-scrap,#3a3122
|
||||||
|
se-genetic-data,#64b979
|
||||||
|
se-significant-data,#3f9751
|
||||||
|
se-experimental-genetic-data,#308249
|
||||||
|
se-atomic-data,#008fd6
|
||||||
|
se-star-probe-data,#ffea4d
|
||||||
|
se-significant-specimen,#00bdb6
|
||||||
|
se-specimen,#26a69a
|
||||||
|
se-bio-sludge,#2c7749
|
||||||
|
se-nutrient-gel,#005c5a
|
||||||
|
se-nutrient-gel-barrel,#004d40
|
||||||
|
mineral-water,#3236f5
|
||||||
|
chlorine,#b0dbde
|
||||||
|
nitric-acid,#ffcc80
|
||||||
|
se-bioscrubber,#174f30
|
||||||
|
se-space-coolant-hot,#da6e16
|
||||||
|
se-space-water,#6c59f7
|
||||||
|
se-chemical-gel,#ad25aa
|
||||||
|
se-vitalic-acid,#7ec44f
|
||||||
|
se-vitalic-reagent,#6db342
|
||||||
|
se-vitalic-epoxy,#5d9f38
|
||||||
|
se-neural-gel,#b39ddb
|
||||||
|
se-neural-gel-2,#9575cd
|
||||||
|
se-plasma-stream,#d50000
|
||||||
|
se-proton-stream,#e040fb
|
||||||
|
se-ion-stream,#651fff
|
||||||
|
se-particle-stream,#304ffe
|
||||||
|
se-vitamelange-extract,#427a2a
|
||||||
|
se-vitamelange-spice,#498c31
|
||||||
|
se-water-ice,#e3f2fd
|
||||||
|
lithium,#9eadb7
|
||||||
|
lithium-chloride,#b0b8c4
|
||||||
|
lithium-sulfur-battery,#db9ad7
|
||||||
|
fertilizer,#4a432b
|
||||||
|
biomass,#33691e
|
||||||
|
biomethanol,#419b5d
|
||||||
|
se-surface-teleporter,#24166a
|
||||||
|
se-observation-frame,#5eab3f
|
||||||
|
se-observation-frame-blank,#5eb83d
|
||||||
|
se-core-fragment-se-beryllium,#6bbd80
|
||||||
|
se-core-fragment-se-cryonite,#0070e0
|
||||||
|
se-core-fragment-se-holmium,#ca6dad
|
||||||
|
se-core-fragment-se-imersite,#931f80
|
||||||
|
se-core-fragment-se-iridium,#c7ced6
|
||||||
|
se-core-fragment-se-naquium,#ffdf29
|
||||||
|
se-core-fragment-se-vita,#62c247
|
||||||
|
se-core-fragment-se-vulcanite,#ca6a16
|
||||||
|
se-core-fragment-omni,#46a5aa
|
||||||
|
se-core-fragment-se-iridium-ore,#409f38
|
||||||
|
se-core-fragment-se-vitamelange,#4ac144
|
||||||
|
se-aeroframe-bulkhead,#4ea9b7
|
||||||
|
se-aeroframe-scaffold,#26a2a6
|
||||||
|
se-aeroframe-pole,#00897b
|
||||||
|
se-heavy-girder,#686f8d
|
||||||
|
se-heavy-bearing,#607d8b
|
||||||
|
se-heavy-composite,#484766
|
||||||
|
se-space-pipe,#4e9bb7
|
||||||
|
se-space-transport-belt,#2280c9
|
||||||
|
se-space-underground-belt,#001cbd
|
||||||
|
se-space-splitter,#0057d1
|
||||||
|
se-space-accumulator,#734bf7
|
||||||
|
se-space-solar-panel,#ffe438
|
||||||
|
se-space-solar-panel-2,#ffa726
|
||||||
|
se-space-elevator-cable,#4b2494
|
||||||
|
se-space-pipe-to-ground,#5191b8
|
||||||
|
se-space-mirror,#d9d9d9
|
||||||
|
se-space-rail,#787a9b
|
||||||
|
se-space-platform-scaffold,#b8b8b8
|
||||||
|
se-space-probe-rocket,#ff913d
|
||||||
|
se-space-capsule,#42a5f5
|
||||||
|
se-meteor-defence-ammo,#ed533b
|
||||||
|
se-dynamic-emitter,#3913aa
|
||||||
|
se-gammaray-detector,#009bb3
|
||||||
|
se-pylon-substation,#ffff52
|
||||||
|
se-rocket-launch-pad,#707070
|
||||||
|
se-rocket-landing-pad,#5e5e5e
|
||||||
|
se-cargo-rocket-fuel-tank,#ff7a05
|
||||||
|
se-cargo-rocket-cargo-pod,#ff985c
|
||||||
|
se-cargo-rocket-section,#ffa570
|
||||||
|
se-cargo-rocket-section-packed,#ffdd75
|
||||||
|
se-lifesupport-canister,#7a32f5
|
||||||
|
se-used-lifesupport-canister,#929bb0
|
||||||
|
se-canister,#898fa9
|
||||||
|
se-magnetic-canister,#ca6da3
|
||||||
|
se-iridium-ore,#348c31
|
||||||
|
se-iridium-ore-crushed,#35973d
|
||||||
|
se-beryllium-ore,#3b7a2a
|
||||||
|
se-beryllium-sulfate,#005461
|
||||||
|
se-holmium-ore,#a53eac
|
||||||
|
se-holmium-ore-crushed,#a93da0
|
||||||
|
se-compact-beacon,#4612a5
|
||||||
|
se-recycling-facility,#49a780
|
||||||
|
se-rocket-science-pack,#e3e3e3
|
||||||
|
lubricant-barrel,#ffc107
|
||||||
|
heavy-oil-barrel,#434028
|
||||||
|
light-oil-barrel,#ffb56b
|
||||||
|
petroleum-gas-barrel,#240038
|
||||||
|
se-material-science-pack-1,#fffc42
|
||||||
|
se-material-science-pack-2,#ffbe1a
|
||||||
|
se-material-science-pack-3,#f5c800
|
||||||
|
se-material-science-pack-4,#e67e00
|
||||||
|
se-material-testing-pack,#f57c00
|
||||||
|
se-material-insight,#ffe0b2
|
||||||
|
se-material-catalogue-1,#efff42
|
||||||
|
se-material-catalogue-2,#ffd11a
|
||||||
|
se-material-catalogue-3,#f5e400
|
||||||
|
se-material-catalogue-4,#e6a100
|
||||||
|
se-astronomic-science-pack-1,#565abd
|
||||||
|
se-astronomic-science-pack-2,#373aa4
|
||||||
|
se-astronomic-insight,#c5cae9
|
||||||
|
se-astronomic-catalogue-1,#6c61c2
|
||||||
|
se-astronomic-catalogue-2,#423bb0
|
||||||
|
se-astronomic-catalogue-3,#2a2c98
|
||||||
|
se-astronomic-catalogue-4,#371b83
|
||||||
|
se-biological-science-pack-1,#69f0ae
|
||||||
|
se-biological-science-pack-2,#00e676
|
||||||
|
se-biological-science-pack-3,#00ad5f
|
||||||
|
se-biological-science-pack-4,#009624
|
||||||
|
se-biological-insight,#b9f6ca
|
||||||
|
se-biological-catalogue-1,#6af0c1
|
||||||
|
se-biological-catalogue-2,#00e68a
|
||||||
|
se-biological-catalogue-3,#00c77e
|
||||||
|
se-biological-catalogue-4,#009431
|
||||||
|
se-energy-science-pack-1,#f17493
|
||||||
|
se-energy-science-pack-2,#f06292
|
||||||
|
se-energy-science-pack-3,#ec407a
|
||||||
|
se-energy-science-pack-4,#d81b60
|
||||||
|
se-energy-insight,#fce4ec
|
||||||
|
se-energy-catalogue-1,#f17482
|
||||||
|
se-energy-catalogue-2,#ee446f
|
||||||
|
se-energy-catalogue-3,#e92549
|
||||||
|
se-energy-catalogue-4,#bf183f
|
||||||
|
se-deep-space-science-pack-1,#bfa6de
|
||||||
|
se-deep-space-science-pack-2,#a27cd0
|
||||||
|
se-deep-space-science-pack-3,#7e57c2
|
||||||
|
se-deep-space-science-pack-4,#6f37b9
|
||||||
|
se-kr-matter-science-pack-1,#ffef8a
|
||||||
|
se-kr-matter-science-pack-2,#e0ff57
|
||||||
|
se-kr-matter-liberation-data,#ffe494
|
||||||
|
blank-tech-card,#898ca9
|
||||||
|
singularity-tech-card,#351566
|
||||||
|
ltn-combinator,#4c1b83
|
||||||
|
ltn-stop,#383a51
|
||||||
|
ltn-delivery-address,#286c4b
|
||||||
|
ltn-provider-stack-threshold,#f34a1b
|
||||||
|
ltn-requester-stack-threshold,#1643f3
|
||||||
|
ltn-provider-threshold,#d45d35
|
||||||
|
ltn-requester-threshold,#5c12a5
|
||||||
|
ltn-max-trains,#621281
|
||||||
|
ltn-max-train-length,#66127d
|
||||||
|
ltn-locked-slots,#534d6f
|
||||||
|
Reference in New Issue
Block a user