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 ( <>