import React, { useMemo, useState, useEffect } from "react";
import { Upload, SplitSquareHorizontal, Maximize2, FileText, Download, Plus, Trash2, RotateCcw, Search, Columns3, PanelLeftClose, PanelRightClose } from "lucide-react";
function formatNumber(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "—";
return num.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
function fileKind(file) {
if (!file) return "none";
if (file.type?.startsWith("image/")) return "image";
if (file.type === "application/pdf" || file.name?.toLowerCase().endsWith(".pdf")) return "pdf";
return "other";
}
function Field({ label, value, onChange, placeholder }) {
return (
);
}
function UploadBox({ side, file, setFile, meta, setMeta, zoom, setZoom, rotation, setRotation, panelMode }) {
const [objectUrl, setObjectUrl] = useState("");
const kind = fileKind(file);
useEffect(() => {
if (!file) {
setObjectUrl("");
return;
}
const url = URL.createObjectURL(file);
setObjectUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const iframeSrc = useMemo(() => {
if (!objectUrl) return "";
if (kind === "pdf") return `${objectUrl}#toolbar=1&navpanes=0&zoom=${Math.round(zoom * 100)}`;
return objectUrl;
}, [objectUrl, kind, zoom]);
return (
setMeta({ ...meta, model: v })} placeholder="LTM 1130-5.1" />
setMeta({ ...meta, setup: v })} placeholder="360°, full counterweight" />
setMeta({ ...meta, boom: v })} placeholder="197' main / 35' jib" />
setMeta({ ...meta, notes: v })} placeholder="Outriggers, offset, deductions..." />
{file ? file.name : "No file uploaded"}
{Math.round(zoom * 100)}%
{!file && (
Drop in a load chart for side-by-side viewing
Use the table below to compare exact capacities by radius, boom length, counterweight, and chart percentage.
)}
{file && kind === "pdf" && (
)}
{file && kind === "image" && (
)}
{file && kind === "other" && (
This file type may not preview in the browser. Upload a PDF or image version of the load chart.
)}
);
}
function ComparisonTable({ rows, setRows }) {
const updateRow = (id, patch) => setRows(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
const deleteRow = (id) => setRows(rows.filter((row) => row.id !== id));
const addRow = () => setRows([...rows, { id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" }]);
return (
Capacity comparison table
Manually enter matching chart points to compare capacity, difference, and chart usage.
);
}
export default function LoadChartCompareApp() {
const [leftFile, setLeftFile] = useState(null);
const [rightFile, setRightFile] = useState(null);
const [leftZoom, setLeftZoom] = useState(1);
const [rightZoom, setRightZoom] = useState(1);
const [leftRotation, setLeftRotation] = useState(0);
const [rightRotation, setRightRotation] = useState(0);
const [syncZoom, setSyncZoom] = useState(true);
const [panelMode, setPanelMode] = useState("both");
const [filter, setFilter] = useState("");
const [leftMeta, setLeftMeta] = useState({ model: "", setup: "", boom: "", notes: "" });
const [rightMeta, setRightMeta] = useState({ model: "", setup: "", boom: "", notes: "" });
const [project, setProject] = useState({ name: "", load: "", target: "75", note: "" });
const [rows, setRows] = useState([
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
]);
useEffect(() => {
if (syncZoom) setRightZoom(leftZoom);
}, [leftZoom, syncZoom]);
const filteredRows = useMemo(() => {
if (!filter.trim()) return rows;
const q = filter.toLowerCase();
return rows.filter((r) => Object.values(r).join(" ").toLowerCase().includes(q));
}, [rows, filter]);
const summary = useMemo(() => {
let aWins = 0;
let bWins = 0;
let closestA = null;
let closestB = null;
rows.forEach((row) => {
const a = Number(row.chartA);
const b = Number(row.chartB);
const load = Number(row.load || project.load);
if (Number.isFinite(a) && Number.isFinite(b)) {
if (a > b) aWins += 1;
if (b > a) bWins += 1;
}
if (load > 0 && a > 0) {
const pct = (load / a) * 100;
if (closestA === null || Math.abs(pct - Number(project.target || 75)) < Math.abs(closestA - Number(project.target || 75))) closestA = pct;
}
if (load > 0 && b > 0) {
const pct = (load / b) * 100;
if (closestB === null || Math.abs(pct - Number(project.target || 75)) < Math.abs(closestB - Number(project.target || 75))) closestB = pct;
}
});
return { aWins, bWins, closestA, closestB };
}, [rows, project.load, project.target]);
const exportCsv = () => {
const headers = ["Project", "Chart A Model", "Chart B Model", "Radius", "Boom/Jib", "Chart A Capacity", "Chart B Capacity", "Load", "A Percent", "B Percent", "B Minus A", "Notes"];
const lines = rows.map((row) => {
const a = Number(row.chartA);
const b = Number(row.chartB);
const load = Number(row.load || project.load);
const aPct = load > 0 && a > 0 ? ((load / a) * 100).toFixed(1) + "%" : "";
const bPct = load > 0 && b > 0 ? ((load / b) * 100).toFixed(1) + "%" : "";
const diff = Number.isFinite(b - a) ? b - a : "";
return [project.name, leftMeta.model, rightMeta.model, row.radius, row.boom, row.chartA, row.chartB, row.load || project.load, aPct, bPct, diff, row.notes]
.map((cell) => `"${String(cell ?? "").replaceAll('"', '""')}"`)
.join(",");
});
const csv = [headers.join(","), ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${project.name || "load-chart-comparison"}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const reset = () => {
setLeftFile(null);
setRightFile(null);
setLeftZoom(1);
setRightZoom(1);
setLeftRotation(0);
setRightRotation(0);
setPanelMode("both");
setFilter("");
setRows([
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
{ id: crypto.randomUUID(), radius: "", boom: "", chartA: "", chartB: "", load: "", notes: "" },
]);
};
return (
Nichols Crane Rental
Load Chart Side-by-Side Compare
Upload two crane charts, view them side by side, and compare capacities by radius, boom length, load weight, and chart percentage.
setProject({ ...project, name: v })} placeholder="RTU pick - Chicago" />
setProject({ ...project, load: v })} placeholder="23000" />
setProject({ ...project, target: v })} placeholder="75" />
setProject({ ...project, note: v })} placeholder="Use 360° capacities only" />
Chart A stronger at
{summary.aWins}
entered comparison rows
Chart B stronger at
{summary.bWins}
entered comparison rows
Closest A usage
{summary.closestA === null ? "—" : `${formatNumber(summary.closestA)}%`}
near target chart %
Closest B usage
{summary.closestB === null ? "—" : `${formatNumber(summary.closestB)}%`}
near target chart %
{(panelMode === "both" || panelMode === "left") && (
)}
{(panelMode === "both" || panelMode === "right") && (
)}
{
if (!filter.trim()) {
setRows(updatedFiltered);
return;
}
const updateMap = new Map(updatedFiltered.map((r) => [r.id, r]));
setRows(rows.map((r) => updateMap.get(r.id) || r).filter((r) => updateMap.has(r.id) || !filteredRows.some((fr) => fr.id === r.id)));
}} />
Use notes
1. Upload charts.
Use manufacturer PDF load charts, screen captures, or individual chart pages.
2. Match chart conditions.
Make sure radius, boom, counterweight, outrigger setup, parts of line, and deductions match before comparing.
3. Export the comparison.
Download a CSV for quote notes, lift planning, or later review.
);
}