12 Commits

Author SHA1 Message Date
Sebastian Seedorf
d212ae3f30 fix: nudge duplicate colors ±2 hue/light for uniqueness
All checks were successful
Build & Push / build (push) Successful in 3m30s
2026-06-04 11:06:41 +02:00
Sebastian Seedorf
955b0a890d feat: item color map, fix regex matching, fix sort order, fix resize handle 2026-06-04 11:04:58 +02:00
Sebastian Seedorf
b9377daa04 fix: visible resize handle via resizeConfig.handleComponent 2026-06-03 15:28:24 +02:00
Sebastian Seedorf
d6c2bb0b6a fix: backend ORDER BY CASE for series sort, default value_asc 2026-06-03 15:08:25 +02:00
Sebastian Seedorf
25db053a7b feat: draggable resizer between plot and legend in chart cards 2026-06-03 13:12:40 +02:00
Sebastian Seedorf
11b4e021fe fix: legend CSS via <style> tag instead of globals.css (Tailwind v4 tree-shaking) 2026-06-03 13:05:33 +02:00
Sebastian Seedorf
654d3849eb fix: legend CSS, SI prefix for x-axis ticks, table sort cleanup 2026-06-03 13:00:34 +02:00
Sebastian Seedorf
3506d1f6c5 feat: y-axis SI prefix, hide tick from legend, fix table sort 2026-06-03 12:52:45 +02:00
Sebastian Seedorf
8c83e8b8e8 feat: tag Docker images with semver alongside SHA and latest
All checks were successful
Build & Push / build (push) Successful in 3m48s
2026-06-02 16:15:30 +02:00
Sebastian Seedorf
399db56499 Add workflow_dispatch trigger for manual runs
Some checks failed
Build & Push / build (push) Failing after 8m47s
2026-05-19 19:10:37 +02:00
Sebastian Seedorf
3ef567a39e Pin actions to @v1 tag
Some checks failed
Build & Push / build (push) Failing after 1m48s
2026-05-19 18:42:10 +02:00
Sebastian Seedorf
1ff03cd09f Add Factorio mod push step and README
Some checks failed
Build & Push / build (push) Has been cancelled
- Add factorio-mod-push step to build.yml (mod-folder: ./plugin)
- Create plugin/README.md as bootstrap for mod portal description
2026-05-19 18:17:10 +02:00
17 changed files with 728 additions and 82 deletions

View File

@@ -1,6 +1,7 @@
name: Build & Push
on:
workflow_dispatch:
push:
branches:
- main
@@ -18,7 +19,7 @@ jobs:
- name: Bump version
id: version
uses: https://git.sebse.de/sebse/actions/version-bump@main
uses: https://git.sebse.de/sebse/actions/version-bump@v1
with:
token: ${{ github.token }}
@@ -28,26 +29,52 @@ jobs:
echo "IMAGE_MIGRATE=git.sebse.de/${{ github.repository }}-migrate" >> $GITHUB_ENV
- name: Docker login
uses: https://git.sebse.de/sebse/actions/docker-login@main
uses: https://git.sebse.de/sebse/actions/docker-login@v1
with:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push app
uses: https://git.sebse.de/sebse/actions/docker-build-push@main
- name: Extract metadata (app)
id: meta
uses: docker/metadata-action@v5
with:
image: ${{ env.IMAGE }}
build-context: ./web
images: ${{ env.IMAGE }}
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
uses: https://git.sebse.de/sebse/actions/docker-build-push@main
uses: docker/build-push-action@v5
with:
image: ${{ env.IMAGE_MIGRATE }}
build-context: ./web
dockerfile: ./web/Dockerfile.migrate
context: ./web
file: ./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
uses: https://git.sebse.de/sebse/actions/helm-package-push@main
uses: https://git.sebse.de/sebse/actions/helm-package-push@v1
with:
chart-path: ${{ env.CHART_PATH }}
version: ${{ steps.version.outputs.version }}
@@ -55,8 +82,15 @@ jobs:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Package and push Factorio mod
uses: https://git.sebse.de/sebse/actions/factorio-mod-push@v1
with:
mod-folder: ./plugin
version: ${{ steps.version.outputs.version }}
factorio-token: ${{ secrets.FACTORIO_TOKEN }}
- name: Deploy to prod
uses: https://git.sebse.de/sebse/actions/helm-deploy@main
uses: https://git.sebse.de/sebse/actions/helm-deploy@v1
with:
app: factorio-signal-exporter
profile: prod

3
plugin/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Signal Exporter
Provides a custom combinator that reads both connected circuit and logistic networks, writing the data to a JSON file.

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(async (req: NextRequest) => {
filter_combinators = null, filter_items = null,
filter_items_exclude = null, filter_items_regex = false,
y_min = null, y_max = null, y_scale = 'linear',
series_limit = 20, order_by = 'time',
series_limit = 20, order_by = 'value_asc',
} = body;
if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 });

View File

@@ -13,7 +13,7 @@ export const GET = withAuth(async (req: NextRequest) => {
const from = p.get('from');
const to = p.get('to');
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 conditions: string[] = [];
@@ -28,12 +28,15 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
// Each pattern is expanded to matching keys (tested against key AND localized name).
// Union all patterns — if a pattern matches nothing, it contributes no keys.
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
conditions.push(`item_key = ANY($${i++})`);
values.push(keys);
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|');
const orConds = [`item_key ~* $${i++}`];
values.push(sqlPattern);
if (localeKeys.length > 0) {
orConds.push(`item_key = ANY($${i++})`);
values.push(localeKeys);
}
conditions.push(`(${orConds.join(' OR ')})`);
} else {
conditions.push(`item_key = ANY($${i++})`);
values.push(itemsWhitelist);
@@ -43,12 +46,15 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
// If blacklist pattern matches nothing, nothing to exclude — skip condition
if (keys.length > 0) {
conditions.push(`item_key != ALL($${i++})`);
values.push(keys);
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|');
const andConds = [`item_key !~* $${i++}`];
values.push(sqlPattern);
if (localeKeys.length > 0) {
andConds.push(`item_key != ALL($${i++})`);
values.push(localeKeys);
}
conditions.push(`(${andConds.join(' AND ')})`);
} else {
conditions.push(`item_key != ALL($${i++})`);
values.push(itemsBlacklist);
@@ -78,10 +84,15 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsWhitelist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length === 0) return NextResponse.json([]);
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(keys);
const localeKeys = [...new Set(itemsWhitelist.flatMap(p => matchKeys(p, localeMap)))];
const sqlPattern = itemsWhitelist.map(p => `(${p})`).join('|');
const orConds = [`item_key ~* $${j++}`];
baseValues.push(sqlPattern);
if (localeKeys.length > 0) {
orConds.push(`item_key = ANY($${j++})`);
baseValues.push(localeKeys);
}
baseConditions.push(`(${orConds.join(' OR ')})`);
} else {
baseConditions.push(`item_key = ANY($${j++})`);
baseValues.push(itemsWhitelist);
@@ -90,11 +101,15 @@ export const GET = withAuth(async (req: NextRequest) => {
if (itemsBlacklist.length > 0) {
if (useRegex) {
const localeMap = getServerLocaleMap();
const keys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
if (keys.length > 0) {
baseConditions.push(`item_key != ALL($${j++})`);
baseValues.push(keys);
const localeKeys = [...new Set(itemsBlacklist.flatMap(p => matchKeys(p, localeMap)))];
const sqlPattern = itemsBlacklist.map(p => `(${p})`).join('|');
const andConds = [`item_key !~* $${j++}`];
baseValues.push(sqlPattern);
if (localeKeys.length > 0) {
andConds.push(`item_key != ALL($${j++})`);
baseValues.push(localeKeys);
}
baseConditions.push(`(${andConds.join(' AND ')})`);
} else {
baseConditions.push(`item_key != ALL($${j++})`);
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})`,
);
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(
`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])],
);
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})`,
);
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(
`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])],
);
return NextResponse.json(result.rows);

View File

@@ -15,3 +15,4 @@ body {
color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
}

87
web/bin/fix-colors.ts Normal file
View 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`);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useState, useCallback } from 'react';
interface HeaderProps {
title: string;
@@ -32,22 +32,55 @@ interface CardShellProps extends HeaderProps {
}
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 (
<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} />
{empty ? (
<EmptyState />
) : (
<>
{/* Plot fills remaining space */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Legend scrolls independently, capped at 25% card height */}
<div
ref={legendContainerRef}
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"
/>
{legendContainerRef && <>
<div
onMouseDown={handleMouseDown}
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>

View File

@@ -2,8 +2,11 @@
import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { useState, useEffect } from 'react';
import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization';
import { getColorMap } from '@/lib/colors';
import type { ColorMap } from '@/lib/colors';
import { CardShell } from './CardShell';
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
import { buildSeriesData } from './seriesData';
@@ -23,6 +26,9 @@ interface Props {
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
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(
(el, w, h, lRef) => {
@@ -44,8 +50,8 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode,
if (lRef.current) lRef.current.appendChild(legendEl);
},
},
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
axes: makeSignalsAxes(timeMode),
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap),
axes: makeSignalsAxes(timeMode, locale),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),

View File

@@ -1,5 +1,9 @@
import { useState, useEffect } from 'react';
import { useApp } from '@/lib/context';
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 type { ChartConfig, SignalRow } from '@/lib/types';
@@ -12,12 +16,14 @@ interface Props {
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp();
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => { getColorMap().then(setColorMap); }, []);
const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) {
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 (
<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('::');
return (
<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>
{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'}`}>
{vals.green?.toLocaleString() ?? '--'}
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
</td>
)}
{config.signal_type !== 'green' && (
<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>
)}
</tr>

View File

@@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot';
import { CardShell } from './CardShell';
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
import { formatSI } from '@/lib/formatNumber';
import { usePlot } from './usePlot';
import type { ChartConfig, UpsRow } 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 = {
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
values: timeMode === 'real'
? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)),
};
return new uPlot({
@@ -49,7 +50,7 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
{ 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: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),

View File

@@ -1,18 +1,8 @@
import uPlot from 'uplot';
import type { ChartConfig } from '@/lib/types';
// --- Color helpers ---
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%)`;
}
import { formatSI } from '@/lib/formatNumber';
import type { ColorMap } from '@/lib/colors';
import { getItemColor } from '@/lib/colors';
const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171';
@@ -23,10 +13,11 @@ export interface SeriesStyle {
}
export function getSeriesStyle(
key: string,
uCombs: number,
uItems: number,
uSigs: number,
key: string,
uCombs: number,
uItems: number,
uSigs: number,
colorMap: ColorMap = new Map(),
): SeriesStyle {
const [combinator, item_key, sig] = key.split('::');
@@ -34,9 +25,9 @@ export function getSeriesStyle(
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
}
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[],
timeMode: 'real' | 'tick',
resolveName: (key: string) => string,
colorMap: ColorMap = new Map(),
): uPlot.Series[] {
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
@@ -153,7 +145,7 @@ export function makeSignalsSeries(
xSeries,
...keys.map(k => {
const [, item_key] = k.split('::');
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs);
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
return {
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
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 [
{
...AXIS_BASE,
...(timeMode === 'real' && {
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
}),
values: timeMode === 'real'
? (_u: uPlot, vals: (number | null)[]) =>
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 },
];
}

View File

@@ -37,7 +37,7 @@ export default function ChartEditor({ initial, onSave, onClose }: Props) {
const [whitelist, setWhitelist] = useState((initial?.filter_items ?? []).join(', '));
const [blacklist, setBlacklist] = useState((initial?.filter_items_exclude ?? []).join(', '));
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 [yMin, setYMin] = useState(initial?.y_min?.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>
<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_desc">Latest highest values</option>
<option value="delta_asc">Biggest decrease (last 10 min)</option>

View File

@@ -151,6 +151,18 @@ export default function Dashboard({ alerts }: Props) {
onLayoutChange={handleLayoutChange}
gridConfig={{ cols: COLS, rowHeight: ROW_HEIGHT, margin: [8, 8] }}
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 => (
<div key={c.id} className="drag-handle cursor-grab active:cursor-grabbing">

35
web/lib/colors.ts Normal file
View 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
View 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
View File

@@ -986,6 +986,7 @@
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -998,6 +999,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1795,6 +1797,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -1968,6 +1971,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1977,6 +1981,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},

View 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
1 item_key color
2 copper-ore #bf8040
3 copper-plate #b87333
4 copper-cable #e65100
5 iron-ore #8d6e63
6 iron-plate #bdbdbd
7 iron-gear-wheel #a0a0a0
8 iron-stick #888888
9 steel-plate #757575
10 steel-gear-wheel #5a5a5a
11 steel-beam #424242
12 stone #a1887f
13 stone-brick #b8956a
14 coal #37474f
15 uranium-ore #66bb6a
16 uranium-238 #7cb342
17 uranium-235 #64dd17
18 crude-oil #1a1a1a
19 heavy-oil #4e342e
20 light-oil #ff8a65
21 petroleum-gas #1a0030
22 lubricant #1b5e20
23 sulfuric-acid #b2dfdb
24 water #1565c0
25 steam #e1f5fe
26 wood #6d4c41
27 raw-wood #3e2723
28 plastic-bar #b0bec5
29 sulfur #fff176
30 explosives #ef5350
31 battery #ff9800
32 empty-barrel #90a4ae
33 filled-barrel #546e7a
34 electronic-circuit #4caf50
35 advanced-circuit #e53935
36 processing-unit #1565d2
37 automation-science-pack #d32f2f
38 logistic-science-pack #43a047
39 military-science-pack #616161
40 chemical-science-pack #00acc1
41 production-science-pack #ff8f00
42 utility-science-pack #7b1fa2
43 space-science-pack #f48fb1
44 pipe #78909c
45 engine-unit #795548
46 electric-engine-unit #4fc3f7
47 flying-robot-frame #5e35b1
48 rocket-fuel #ff5722
49 rocket-control-unit #2e7d32
50 low-density-structure #bcaaa4
51 heat-pipe #bf360c
52 heat-exchanger #263238
53 steam-turbine #455a64
54 concrete #9e9e9e
55 refined-concrete #6d6d6d
56 landfill #388e3c
57 cliff-explosives #d84315
58 nuclear-fuel #00c853
59 solid-fuel #0d47a1
60 grenade #5d4037
61 cluster-grenade #c62828
62 landmine #4a148c
63 fish #29b6f6
64 glass #4dd0e1
65 rail #9e9e9e
66 rail-signal #449c53
67 rail-chain-signal #e33d2b
68 train-stop #323f48
69 locomotive #ce3c2c
70 cargo-wagon #937966
71 fluid-wagon #1450b8
72 artillery-wagon #3d3d3d
73 artillery-turret #383838
74 flamethrower-turret #cc4e14
75 gun-turret #5c5c5c
76 laser-turret #e45d2f
77 radar #378b45
78 roboport #2196f3
79 construction-robot #449c5f
80 logistic-robot #133baa
81 speed-module #f44336
82 speed-module-2 #e91e63
83 speed-module-3 #9c27b0
84 speed-module-5 #4c1178
85 effectivity-module #4cae70
86 effectivity-module-2 #3a9256
87 effectivity-module-3 #30823f
88 effectivity-module-4 #1d6327
89 productivity-module #ffb005
90 productivity-module-2 #ef6c00
91 productivity-module-3 #eb6600
92 productivity-module-5 #c5500d
93 beacon #2078f3
94 substation #b427b4
95 medium-electric-pole #687d8d
96 big-electric-pole #566c7b
97 small-electric-pole #8c9eab
98 small-iron-electric-pole #8996a9
99 steel-chest #787878
100 iron-chest #907c64
101 wooden-chest #634b3b
102 transport-belt #b3b3b3
103 fast-transport-belt #50b1f7
104 express-transport-belt #e56f34
105 underground-belt #bfbfbf
106 fast-underground-belt #4193f6
107 express-underground-belt #e48d2f
108 splitter #bdbdbd
109 fast-splitter #3277f5
110 express-splitter #dd8a1d
111 long-handed-inserter #80604d
112 fast-inserter #80664d
113 burner-inserter #7c694b
114 inserter #7c704b
115 stack-inserter #47322a
116 burner-mining-drill #706943
117 electric-mining-drill #737345
118 area-mining-drill #7c804d
119 electric-furnace #384751
120 industrial-furnace #475666
121 assembling-machine-3 #666666
122 centrifuge #546578
123 chemical-plant #0092c7
124 oil-refinery #0f0f0f
125 pumpjack #433428
126 offshore-pump #162eca
127 boiler #9e9e9e
128 steam-engine #6c7d93
129 solar-panel #ffb74d
130 accumulator #0c59e9
131 lamp #ffeb3b
132 constant-combinator #495369
133 decider-combinator #4d556f
134 arithmetic-combinator #585b7e
135 power-switch #ffb405
136 programmable-speaker #751a89
137 aai-signal-receiver #323c48
138 aai-signal-transmitter #2a2f3c
139 textplate-small-copper #e68600
140 used-up-uranium-fuel-cell #34384b
141 uranium-fuel-cell #5db669
142 electric-motor #5976f7
143 motor #4652f6
144 automation-core #1712a5
145 iron-beam #878787
146 kr-advanced-solar-panel #ffc23d
147 kr-advanced-transport-belt #26c6da
148 kr-advanced-loader #0070a3
149 kr-advanced-splitter #00bcd4
150 kr-superior-inserter #74804d
151 kr-superior-filter-inserter #5d4437
152 kr-superior-long-inserter #6f804d
153 kr-superior-long-filter-inserter #5d4737
154 kr-superior-underground-belt #0054a8
155 kr-superior-loader #0048bd
156 kr-fuel-refinery #bb580c
157 kr-quarry-drill #68804d
158 kr-express-loader #00838f
159 kr-electric-mining-drill-mk2 #47382a
160 kr-steel-pipe #6e6e6e
161 kr-steel-pipe-to-ground #787878
162 kr-fluid-storage-2 #2415c1
163 kr-se-loader #0039b3
164 beryllium #81c784
165 beryllium-ore #558b2f
166 beryllium-sulfate #00695c
167 holmium #ba68c8
168 holmium-ore #ab47bc
169 holmium-solution #e1bee7
170 cryonite #00a8e0
171 cryonite-rod #29aedb
172 cryonite-solution #80deea
173 iridium #cfd8dc
174 iridium-ore #689f38
175 iridium-ingot #e0e0e0
176 vulcanite #ff7043
177 vulcanite-block #e64a19
178 vulcanite-powder #ffab91
179 imersite #8e24aa
180 imersite-crystal #283593
181 imersite-powder #ce93d8
182 vita #8bc34a
183 vita-extract #19572a
184 vita-germination #006064
185 naquium #ffd54f
186 naquium-ore #ffca28
187 naquium-ingot #fff9c4
188 methane-ice #b2ebf2
189 core-fragment #4db6ac
190 rare-metals #b0b0b0
191 raw-rare-metals #a8a8a8
192 raw-imersite #821e8f
193 sand #c2b280
194 silicon #6c7893
195 quartz #e6e6e6
196 coke #474747
197 se-copper-ingot #b08130
198 se-iron-ingot #84755c
199 se-steel-ingot #707070
200 se-beryllium-ingot #87c995
201 se-holmium-ingot #c056c2
202 se-iridium-ingot #d6dce1
203 se-cryonite-rod #2397d1
204 se-cryonite-slush #7bccea
205 se-vulcanite-crushed #ff7b29
206 se-vulcanite-enriched #ff6d29
207 se-vulcanite-block #dc5a18
208 imersium-plate #8e1d9a
209 se-kr-imersium-sulfide #9f1e96
210 se-kr-fine-imersite-powder #d58bd5
211 enriched-iron #7e7358
212 electronic-components #4eb183
213 empty-data-card #512da8
214 heat-shielding #00bfa5
215 thermodynamic-boiler #311b92
216 cryogenic-plant #0097a7
217 core-drill #3d5afe
218 core-miner #1a237e
219 se-quantum-processor #123db5
220 se-holmium-cable #c256bb
221 se-holmium-solenoid #c45aae
222 se-superconductive-cable #1c176e
223 se-data-storage-substrate #4527a0
224 se-machine-learning-data #3949ab
225 se-empty-data #5c6bc0
226 se-broken-data #7986cb
227 se-junk-data #9fa8da
228 se-scrap #757575
229 se-contaminated-scrap #3a3122
230 se-genetic-data #64b979
231 se-significant-data #3f9751
232 se-experimental-genetic-data #308249
233 se-atomic-data #008fd6
234 se-star-probe-data #ffea4d
235 se-significant-specimen #00bdb6
236 se-specimen #26a69a
237 se-bio-sludge #2c7749
238 se-nutrient-gel #005c5a
239 se-nutrient-gel-barrel #004d40
240 mineral-water #3236f5
241 chlorine #b0dbde
242 nitric-acid #ffcc80
243 se-bioscrubber #174f30
244 se-space-coolant-hot #da6e16
245 se-space-water #6c59f7
246 se-chemical-gel #ad25aa
247 se-vitalic-acid #7ec44f
248 se-vitalic-reagent #6db342
249 se-vitalic-epoxy #5d9f38
250 se-neural-gel #b39ddb
251 se-neural-gel-2 #9575cd
252 se-plasma-stream #d50000
253 se-proton-stream #e040fb
254 se-ion-stream #651fff
255 se-particle-stream #304ffe
256 se-vitamelange-extract #427a2a
257 se-vitamelange-spice #498c31
258 se-water-ice #e3f2fd
259 lithium #9eadb7
260 lithium-chloride #b0b8c4
261 lithium-sulfur-battery #db9ad7
262 fertilizer #4a432b
263 biomass #33691e
264 biomethanol #419b5d
265 se-surface-teleporter #24166a
266 se-observation-frame #5eab3f
267 se-observation-frame-blank #5eb83d
268 se-core-fragment-se-beryllium #6bbd80
269 se-core-fragment-se-cryonite #0070e0
270 se-core-fragment-se-holmium #ca6dad
271 se-core-fragment-se-imersite #931f80
272 se-core-fragment-se-iridium #c7ced6
273 se-core-fragment-se-naquium #ffdf29
274 se-core-fragment-se-vita #62c247
275 se-core-fragment-se-vulcanite #ca6a16
276 se-core-fragment-omni #46a5aa
277 se-core-fragment-se-iridium-ore #409f38
278 se-core-fragment-se-vitamelange #4ac144
279 se-aeroframe-bulkhead #4ea9b7
280 se-aeroframe-scaffold #26a2a6
281 se-aeroframe-pole #00897b
282 se-heavy-girder #686f8d
283 se-heavy-bearing #607d8b
284 se-heavy-composite #484766
285 se-space-pipe #4e9bb7
286 se-space-transport-belt #2280c9
287 se-space-underground-belt #001cbd
288 se-space-splitter #0057d1
289 se-space-accumulator #734bf7
290 se-space-solar-panel #ffe438
291 se-space-solar-panel-2 #ffa726
292 se-space-elevator-cable #4b2494
293 se-space-pipe-to-ground #5191b8
294 se-space-mirror #d9d9d9
295 se-space-rail #787a9b
296 se-space-platform-scaffold #b8b8b8
297 se-space-probe-rocket #ff913d
298 se-space-capsule #42a5f5
299 se-meteor-defence-ammo #ed533b
300 se-dynamic-emitter #3913aa
301 se-gammaray-detector #009bb3
302 se-pylon-substation #ffff52
303 se-rocket-launch-pad #707070
304 se-rocket-landing-pad #5e5e5e
305 se-cargo-rocket-fuel-tank #ff7a05
306 se-cargo-rocket-cargo-pod #ff985c
307 se-cargo-rocket-section #ffa570
308 se-cargo-rocket-section-packed #ffdd75
309 se-lifesupport-canister #7a32f5
310 se-used-lifesupport-canister #929bb0
311 se-canister #898fa9
312 se-magnetic-canister #ca6da3
313 se-iridium-ore #348c31
314 se-iridium-ore-crushed #35973d
315 se-beryllium-ore #3b7a2a
316 se-beryllium-sulfate #005461
317 se-holmium-ore #a53eac
318 se-holmium-ore-crushed #a93da0
319 se-compact-beacon #4612a5
320 se-recycling-facility #49a780
321 se-rocket-science-pack #e3e3e3
322 lubricant-barrel #ffc107
323 heavy-oil-barrel #434028
324 light-oil-barrel #ffb56b
325 petroleum-gas-barrel #240038
326 se-material-science-pack-1 #fffc42
327 se-material-science-pack-2 #ffbe1a
328 se-material-science-pack-3 #f5c800
329 se-material-science-pack-4 #e67e00
330 se-material-testing-pack #f57c00
331 se-material-insight #ffe0b2
332 se-material-catalogue-1 #efff42
333 se-material-catalogue-2 #ffd11a
334 se-material-catalogue-3 #f5e400
335 se-material-catalogue-4 #e6a100
336 se-astronomic-science-pack-1 #565abd
337 se-astronomic-science-pack-2 #373aa4
338 se-astronomic-insight #c5cae9
339 se-astronomic-catalogue-1 #6c61c2
340 se-astronomic-catalogue-2 #423bb0
341 se-astronomic-catalogue-3 #2a2c98
342 se-astronomic-catalogue-4 #371b83
343 se-biological-science-pack-1 #69f0ae
344 se-biological-science-pack-2 #00e676
345 se-biological-science-pack-3 #00ad5f
346 se-biological-science-pack-4 #009624
347 se-biological-insight #b9f6ca
348 se-biological-catalogue-1 #6af0c1
349 se-biological-catalogue-2 #00e68a
350 se-biological-catalogue-3 #00c77e
351 se-biological-catalogue-4 #009431
352 se-energy-science-pack-1 #f17493
353 se-energy-science-pack-2 #f06292
354 se-energy-science-pack-3 #ec407a
355 se-energy-science-pack-4 #d81b60
356 se-energy-insight #fce4ec
357 se-energy-catalogue-1 #f17482
358 se-energy-catalogue-2 #ee446f
359 se-energy-catalogue-3 #e92549
360 se-energy-catalogue-4 #bf183f
361 se-deep-space-science-pack-1 #bfa6de
362 se-deep-space-science-pack-2 #a27cd0
363 se-deep-space-science-pack-3 #7e57c2
364 se-deep-space-science-pack-4 #6f37b9
365 se-kr-matter-science-pack-1 #ffef8a
366 se-kr-matter-science-pack-2 #e0ff57
367 se-kr-matter-liberation-data #ffe494
368 blank-tech-card #898ca9
369 singularity-tech-card #351566
370 ltn-combinator #4c1b83
371 ltn-stop #383a51
372 ltn-delivery-address #286c4b
373 ltn-provider-stack-threshold #f34a1b
374 ltn-requester-stack-threshold #1643f3
375 ltn-provider-threshold #d45d35
376 ltn-requester-threshold #5c12a5
377 ltn-max-trains #621281
378 ltn-max-train-length #66127d
379 ltn-locked-slots #534d6f