Files
brevyscribe/src/10th/ShortSummaryTable.jsx
T
2026-06-24 19:13:45 +02:00

729 lines
19 KiB
React

import React, { useState } from "react";
import { Bar } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip,
Legend,
Title,
} from "chart.js";
import cardBackground from "../assets/cardBackground.png";
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Tooltip,
Legend,
Title,
);
const normalizeName = (value = "") =>
value
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
const getNameMatchScore = (statName = "", modelName = "") => {
const normalizedStat = normalizeName(statName);
const normalizedModel = normalizeName(modelName);
if (!normalizedStat || !normalizedModel) {
return 0;
}
let score = 0;
if (normalizedStat === normalizedModel) {
score += 1000;
}
if (
normalizedStat.includes(normalizedModel) ||
normalizedModel.includes(normalizedStat)
) {
score += 300;
}
const statTokens = new Set(normalizedStat.split(" "));
const modelTokens = new Set(normalizedModel.split(" "));
for (const token of statTokens) {
if (token && modelTokens.has(token)) {
score += 30;
}
}
const statHasSergeant = statTokens.has("sergeant");
const modelHasSergeant = modelTokens.has("sergeant");
if (statHasSergeant && modelHasSergeant) {
score += 120;
}
if (statHasSergeant !== modelHasSergeant) {
score -= 80;
}
if (statTokens.has("squad") && modelHasSergeant) {
score -= 100;
}
let prefixMatches = 0;
const maxPrefixLength = Math.min(
normalizedStat.length,
normalizedModel.length,
);
for (let i = 0; i < maxPrefixLength; i++) {
if (normalizedStat[i] !== normalizedModel[i]) {
break;
}
prefixMatches++;
}
return score + prefixMatches;
};
const getModelCountForStat = (unit, stat) => {
const models = unit?.models || [];
const modelStats = unit?.modelStats || [];
if (!models.length) {
return 1;
}
if (modelStats.length <= 1) {
return models.reduce((sum, model) => sum + (model.count || 0), 0) || 1;
}
let bestModel = models[0];
let bestScore = getNameMatchScore(stat?.name, bestModel?.name);
for (const model of models.slice(1)) {
const score = getNameMatchScore(stat?.name, model?.name);
if (
score > bestScore ||
(score === bestScore && (model.count || 0) > (bestModel.count || 0))
) {
bestModel = model;
bestScore = score;
}
}
return bestModel?.count || 1;
};
const getUnitTotalWounds = (unit) =>
unit?.modelStats?.reduce(
(sum, stat) => sum + (stat.wounds || 0) * getModelCountForStat(unit, stat),
0,
) || 0;
const getUnitTotalOc = (unit) =>
unit?.modelStats?.reduce(
(sum, stat) => sum + (stat.oc || 0) * getModelCountForStat(unit, stat),
0,
) || 0;
const getUnitTotalModels = (unit) =>
unit?.models?.reduce((sum, model) => sum + (model.count || 0), 0) || 1;
const getUnitPointsPerModel = (unit) => {
const totalModels = getUnitTotalModels(unit);
return totalModels > 0 ? (unit?.cost?.points || 0) / totalModels : 0;
};
const getDefensiveProfile = (unit, stat = {}, modelCount = 1) => {
const toughness = stat.toughness || 0;
const wounds = stat.wounds || 0;
const save = stat.save || 0;
const pointsPerModel = getUnitPointsPerModel(unit);
const cheapBodies = pointsPerModel > 0 && pointsPerModel <= 10;
const cheapMultiWoundBodies = pointsPerModel > 0 && pointsPerModel <= 15;
const eliteCost = pointsPerModel >= 20;
const numerousBodies = modelCount >= 15 || (modelCount >= 10 && cheapBodies);
const hordeBodies =
(wounds <= 2 && cheapBodies && numerousBodies) ||
(toughness <= 4 && wounds <= 1);
const swarmBodies = toughness <= 4 && wounds >= 3 && cheapMultiWoundBodies;
if (toughness >= 10) {
return "Heavy vehicles / monsters";
}
if (toughness >= 7) {
return "Light vehicles / monsters";
}
if (hordeBodies || swarmBodies) {
return "Horde / Swarm";
}
if (toughness >= 6 || (eliteCost && wounds >= 3) || (save > 0 && save <= 3)) {
return "Elite bodies";
}
return "Mixed infantry";
};
const getSkewMeterData = (units) => {
const profileWeights = new Map();
let totalWeight = 0;
let totalModels = 0;
for (const unit of units) {
const modelStats = unit.modelStats?.length ? unit.modelStats : [{}];
totalModels += getUnitTotalModels(unit);
const weightedProfiles = modelStats.map((stat) => {
const modelCount = getModelCountForStat(unit, stat);
const woundShare = Math.max((stat.wounds || 1) * modelCount, modelCount);
return {
profile: getDefensiveProfile(unit, stat, modelCount),
woundShare,
};
});
const unitWoundShare = weightedProfiles.reduce(
(sum, profile) => sum + profile.woundShare,
0,
);
for (const { profile, woundShare } of weightedProfiles) {
const weight =
unitWoundShare > 0
? (unit.cost.points * woundShare) / unitWoundShare
: 0;
profileWeights.set(profile, (profileWeights.get(profile) || 0) + weight);
totalWeight += weight;
}
}
const profiles = Array.from(profileWeights, ([name, weight]) => ({
name,
weight,
share: totalWeight > 0 ? weight / totalWeight : 0,
})).sort((a, b) => b.weight - a.weight);
const dominantProfile = profiles[0] || { name: "No profile", share: 0 };
const score = Math.round(
Math.min(100, Math.max(0, ((dominantProfile.share - 0.35) / 0.55) * 100)),
);
let label = "Balanced";
if (score >= 80) {
label = "Oops, all stat-check";
} else if (score >= 50) {
label = "Oh Lawd He Skewin";
} else if (score >= 35) {
label = "Moderate skew";
} else if (score >= 20) {
label = "Light skew";
}
return {
score,
label,
dominantProfile,
profiles,
totalModels,
};
};
export const ShortSummaryTable = ({
force,
primaryColor,
name,
subtitle,
points,
}) => {
const [hide, setHide] = useState(false);
const [hideSkewMeter, setHideSkewMeter] = useState(false);
const { units, factionRules, rules, catalog } = force;
const sortedUnits = units.slice().sort((a, b) => {
// sort by points cost desc first, then by name
if (a.cost.points < b.cost.points) return 1;
if (a.cost.points > b.cost.points) return -1;
return a.name.localeCompare(b.name);
});
// Prepare data for the bar chart, group by toughness and sum points cost
const groupedByToughness = units.reduce((acc, unit) => {
const toughness = unit.modelStats?.[0]?.toughness || 0;
if (!acc[toughness]) {
acc[toughness] = { totalPoints: 0, unitNames: [] };
}
acc[toughness].totalPoints += unit.cost.points;
if (!acc[toughness].unitNames.includes(unit.name)) {
acc[toughness].unitNames.push(unit.name);
}
return acc;
}, {});
const groupedChartDataToughness = Object.entries(groupedByToughness)
.map(([toughness, { totalPoints, unitNames }]) => ({
toughness: Number.parseInt(toughness, 10),
totalPoints,
unitNames,
}))
.sort((a, b) => a.toughness - b.toughness);
// Prepare data for the bar chart, group by movement and sum points cost
const groupedByMovement = units.reduce((acc, unit) => {
const movement = unit.modelStats?.[0]?.move || 0;
if (!acc[movement]) {
acc[movement] = { totalPoints: 0, unitNames: [] };
}
acc[movement].totalPoints += unit.cost.points;
if (!acc[movement].unitNames.includes(unit.name)) {
acc[movement].unitNames.push(unit.name);
}
return acc;
}, {});
const groupedChartDataMovement = Object.entries(groupedByMovement)
.map(([movement, { totalPoints, unitNames }]) => ({
movement: Number.parseInt(movement, 10),
totalPoints,
unitNames,
}))
.sort((a, b) => a.movement - b.movement);
// Prepare data for the bar chart, group by save and sum points cost
const groupedBySave = units.reduce((acc, unit) => {
const save = unit.modelStats?.[0]?.save || 0;
if (!acc[save]) {
acc[save] = { totalPoints: 0, unitNames: [] };
}
acc[save].totalPoints += unit.cost.points;
if (!acc[save].unitNames.includes(unit.name)) {
acc[save].unitNames.push(unit.name);
}
return acc;
}, {});
const groupedChartDataSave = Object.entries(groupedBySave)
.map(([save, { totalPoints, unitNames }]) => ({
save: Number.parseInt(save, 10),
totalPoints,
unitNames,
}))
.sort((a, b) => a.save - b.save);
const totalArmyWounds = sortedUnits.reduce(
(sum, unit) => sum + getUnitTotalWounds(unit),
0,
);
const totalArmyPoints = sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
);
const canShowSkewMeter = sortedUnits.length > 1 && totalArmyPoints >= 500;
const showSkewMeter = canShowSkewMeter && !hideSkewMeter;
const skewMeterData = showSkewMeter ? getSkewMeterData(sortedUnits) : null;
return (
<>
<div className="flex justify-end gap-3">
{canShowSkewMeter && (
<label
className="print-display-none"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
userSelect: "none",
}}
>
<input
type="checkbox"
checked={hideSkewMeter}
onChange={() => setHideSkewMeter(!hideSkewMeter)}
/>
<span className="print-display-none">Hide Skew-O-Meter</span>
</label>
)}
<label
className="print-display-none"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
userSelect: "none",
}}
>
<input
type="checkbox"
checked={hide}
onChange={() => setHide(!hide)}
/>
<span className="print-display-none">Don't print this card.</span>
</label>
</div>
<div className={`flex flex-col ${hide ? "print-display-none" : ""}`}>
<div
className="flex justify-between"
style={{
backgroundColor: "var(--primary-color)",
color: "#fff",
padding: "4px 16px",
fontSize: " 1.7em",
fontWeight: "800",
textTransform: "uppercase",
}}
>
{subtitle ? (
<span className="flex flex-col leading-none">
<span>{name}</span>
<span
style={{
fontSize: ".55em",
fontWeight: 700,
lineHeight: 1.15,
marginTop: 2,
}}
>
{subtitle}
</span>
</span>
) : (
<span>{name}</span>
)}
<span>{points} pts</span>
</div>
<div
className="mb-4 flex flex-wrap items-start border-2 border-[var(--primary-color)] print:flex-nowrap"
style={{
background: `url(${cardBackground})`,
backgroundSize: "cover",
}}
>
<div className="mt-4 table w-full border-collapse overflow-hidden md:w-[50%] md:flex-1">
<div className="table-header-group bg-[var(--primary-color)] font-bold uppercase text-white">
<div className="table-row">
<div
className="table-cell border border-[var(--primary-color)] px-4 py-1"
style={{
fontSize: "1.1em",
color: "#fff",
fontWeight: 600,
}}
>
Unit Name
</div>
<div
className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"
style={{
fontSize: "1.1em",
color: "#fff",
fontWeight: 600,
}}
>
W
</div>
<div
className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"
style={{
fontSize: "1.1em",
color: "#fff",
fontWeight: 600,
}}
>
OC
</div>
<div
className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"
style={{
fontSize: "1.1em",
color: "#fff",
fontWeight: 600,
}}
>
Pts/W
</div>
<div
className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"
style={{
fontSize: "1.1em",
color: "#fff",
fontWeight: 600,
}}
>
Pts
</div>
</div>
</div>
<div className="table-row-group" style={{ lineHeight: 1.05 }}>
{sortedUnits.map((unit, index) => {
const {
name,
cost,
models,
modelStats,
abilities,
rangedWeapons,
meleeWeapons,
} = unit;
let count =
models?.filter((model) => model.count > 0)?.length === 1
? models?.[0]?.count || 1
: 1;
if (modelStats?.length === 1) {
// sum of all counts of the models
count =
models?.reduce((sum, model) => sum + model.count, 0) || 1;
}
const totalWounds = getUnitTotalWounds(unit);
return (
<div
key={name + index}
className={`table-row ${index % 2 === 0 ? "" : "bg-[#c4c4c480]"}`}
>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1">
<div className="flex flex-wrap items-center gap-2 gap-y-1">
{count > 1 ? `${count}x ` : ""}
{name}
{abilities?.Abilities?.keys()?.some?.(
(key) => key === "Leader",
) && (
<span
style={{
fontSize: ".8em",
fontWeight: 700,
color: "var(--primary-color)",
textTransform: "uppercase",
lineHeight: 1.05,
}}
>
[Leader]
</span>
)}
</div>
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{totalWounds}
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{getUnitTotalOc(unit)}
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{totalWounds > 0
? (cost.points / totalWounds).toFixed(1)
: "—"}
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{cost.points} pts
</div>
</div>
);
})}
<div className="table-row bg-[var(--primary-color)] font-bold text-white">
<div className="table-cell border border-[var(--primary-color)] px-4 py-1">
{sortedUnits.length} Units
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{/* Sum of all wound values */}
{sortedUnits.reduce(
(sum, unit) => sum + getUnitTotalWounds(unit),
0,
)}
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{/* Sum of all OC values */}
{sortedUnits.reduce(
(sum, unit) => sum + getUnitTotalOc(unit),
0,
)}
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{" "}
{totalArmyWounds > 0
? (
sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
) / totalArmyWounds
).toFixed(1)
: "—"}
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{" "}
{sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
)}{" "}
pts
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2.5 self-stretch border-[var(--primary-color)] p-4 pb-2 pt-3.5 md:w-[50%] md:flex-1 md:border-l-2 print:w-[100%]">
{showSkewMeter && (
<SkewMeter data={skewMeterData} primaryColor={primaryColor} />
)}
<ChartComponent
data={{
labels: groupedChartDataMovement.map(
(data) => `${data.movement}"`,
),
datasets: [
{
label: "Total Cost (pts)",
data: groupedChartDataMovement.map(
(data) => data.totalPoints,
),
backgroundColor: primaryColor,
unitNames: groupedChartDataMovement.map(
(data) => data.unitNames,
),
},
],
}}
title="MOVEMENT OF YOUR UNITS"
/>
<ChartComponent
data={{
labels: groupedChartDataToughness.map(
(data) => `${data.toughness}`,
),
datasets: [
{
label: "Total Cost (pts)",
data: groupedChartDataToughness.map(
(data) => data.totalPoints,
),
backgroundColor: primaryColor,
unitNames: groupedChartDataToughness.map(
(data) => data.unitNames,
),
},
],
}}
title="TOUGHNESS OF YOUR UNITS"
/>
<ChartComponent
data={{
labels: groupedChartDataSave.map((data) => `${data.save}+`),
datasets: [
{
label: "Total Cost (pts)",
data: groupedChartDataSave.map((data) => data.totalPoints),
backgroundColor: primaryColor,
unitNames: groupedChartDataSave.map(
(data) => data.unitNames,
),
},
],
}}
title="SAVE CHARACTERISTIC OF YOUR UNITS"
/>
</div>
</div>
</div>
</>
);
};
const SkewMeter = ({ data, primaryColor }) => {
const topProfiles = data.profiles;
return (
<div className="w-full border-2 border-[var(--primary-color)] bg-white/80 p-3 text-black">
<div className="flex items-end justify-between gap-3">
<div>
<div className="text-[0.8em] font-bold uppercase leading-none text-[var(--primary-color)]">
Skew-O-Meter
</div>
<div className="text-[1.35em] font-extrabold uppercase leading-tight">
{data.label}
</div>
</div>
<div className="text-right text-[1.6em] font-extrabold leading-none">
{data.score}
<span className="text-[0.48em] font-bold">/100</span>
</div>
</div>
<div className="mt-2 h-3 overflow-hidden border border-[var(--primary-color)] bg-white">
<div
className="h-full"
style={{
width: `${data.score}%`,
backgroundColor: primaryColor,
}}
/>
</div>
<div className="mt-2 grid gap-1 text-[0.76em] font-semibold leading-tight">
{topProfiles.map((profile, index) => (
<div
key={profile.name}
className={`grid grid-cols-[1fr_3rem] items-center gap-2 ${index === 0 ? "font-extrabold uppercase" : ""}`}
>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 shrink-0"
style={{ backgroundColor: primaryColor }}
/>
<span>{profile.name}</span>
</div>
<span className="text-right">
{Math.round(profile.share * 100)}%
</span>
</div>
))}
</div>
</div>
);
};
const ChartComponent = ({ data, title }) => {
return (
<div className="relative w-full" style={{ height: "150px" }}>
<Bar
data={data}
options={{
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
color: "#000", // Set y-axis tick color
},
},
x: {
ticks: {
color: "#000", // Set x-axis tick color
},
},
},
plugins: {
title: {
display: true,
text: title,
font: {
size: 14,
weight: "bold",
family: "Noto Sans",
},
color: "#000",
align: "start",
},
legend: {
display: false,
},
tooltip: {
displayColors: false,
callbacks: {
title: (context) => "",
label: (context) => {
// return unit names for the bar
return context.dataset.unitNames[context.dataIndex];
},
},
},
},
}}
/>
</div>
);
};