soroban-abacus-flashcards/packages/templates/interactive-gallery-demo.html

721 lines
21 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🧮 Interactive Soroban Gallery - Demo</title>
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.development.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://unpkg.com/react-spring@9.7.0/dist/react-spring.umd.js"></script>
<script src="https://unpkg.com/@use-gesture/react@10.3.0/dist/index.umd.js"></script>
<style>
body {
margin: 0;
padding: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f5f5;
font-size: 1.2rem;
color: #666;
}
#root {
min-height: 100vh;
}
</style>
</head>
<body>
<div id="loading">🧮 Loading Interactive Soroban Gallery...</div>
<div id="root"></div>
<script type="text/babel">
const { useState, useCallback, useMemo, useEffect } = React;
const { useSpring, animated, config } = window.ReactSpring;
const { useDrag } = window.ReactUseGesture;
// AbacusReact component implementation
function useAbacusDimensions(columns, scaleFactor = 1) {
return useMemo(() => {
const baseBeadSize = 12 * scaleFactor;
const baseRodSpacing = 25 * scaleFactor;
const baseMargin = 20 * scaleFactor;
const heavenEarthGap = 30 * scaleFactor;
const barThickness = 2 * scaleFactor;
const width = columns * baseRodSpacing + 2 * baseMargin;
const height =
baseBeadSize * 6 + heavenEarthGap + barThickness + 2 * baseMargin;
return {
width,
height,
rodSpacing: baseRodSpacing,
beadSize: baseBeadSize,
};
}, [columns, scaleFactor]);
}
function useAbacusState(initialValue = 0) {
const [value, setValue] = useState(initialValue);
const getColumnValue = useCallback(
(columnIndex, totalColumns) => {
const digits = value
.toString()
.padStart(totalColumns, "0")
.split("")
.map(Number);
return digits[columnIndex] || 0;
},
[value],
);
const toggleBead = useCallback(
(bead, totalColumns) => {
const currentColumnValue = getColumnValue(
bead.columnIndex,
totalColumns,
);
let newColumnValue;
if (bead.type === "heaven") {
newColumnValue = bead.active
? currentColumnValue - 5
: currentColumnValue + 5;
} else {
if (bead.active) {
newColumnValue = Math.min(currentColumnValue, bead.position);
} else {
newColumnValue = Math.max(
currentColumnValue,
bead.position + 1,
);
}
}
const digits = value
.toString()
.padStart(totalColumns, "0")
.split("")
.map(Number);
digits[bead.columnIndex] = Math.max(0, Math.min(9, newColumnValue));
const newTotal = parseInt(digits.join(""), 10);
setValue(newTotal);
},
[value, getColumnValue],
);
return { value, setValue, toggleBead };
}
const COLOR_PALETTES = {
default: ["#2E86AB", "#A23B72", "#F18F01", "#6A994E", "#BC4B51"],
colorblind: ["#0173B2", "#DE8F05", "#CC78BC", "#029E73", "#D55E00"],
mnemonic: ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"],
grayscale: ["#000000", "#404040", "#808080", "#b0b0b0", "#d0d0d0"],
nature: ["#4E79A7", "#F28E2C", "#E15759", "#76B7B2", "#59A14F"],
};
function getBeadColor(bead, totalColumns, colorScheme, colorPalette) {
const inactiveColor = "#d3d3d3";
if (!bead.active) return inactiveColor;
switch (colorScheme) {
case "place-value": {
const placeIndex = totalColumns - bead.columnIndex - 1;
const colors =
COLOR_PALETTES[colorPalette] || COLOR_PALETTES.default;
return colors[placeIndex % colors.length];
}
case "alternating":
return bead.columnIndex % 2 === 0 ? "#1E88E5" : "#43A047";
case "heaven-earth":
return bead.type === "heaven" ? "#E53E3E" : "#3182CE";
default:
return "#000000";
}
}
function calculateBeadStates(value, columns) {
const digits = value
.toString()
.padStart(columns, "0")
.split("")
.map(Number);
return digits.map((digit, columnIndex) => {
const beads = [];
const heavenActive = digit >= 5;
beads.push({
type: "heaven",
value: 5,
active: heavenActive,
position: 0,
columnIndex,
});
const earthValue = digit % 5;
for (let i = 0; i < 4; i++) {
beads.push({
type: "earth",
value: 1,
active: i < earthValue,
position: i,
columnIndex,
});
}
return beads;
});
}
const Bead = ({
bead,
x,
y,
size,
shape,
color,
animated,
draggable,
onClick,
}) => {
const [{ x: springX, y: springY }, api] = useSpring(() => ({ x, y }));
const bind = useDrag(
({ movement: [mx, my], down }) => {
if (!draggable) return;
if (down) {
api.start({ x: x + mx, y: y + my, immediate: true });
} else {
api.start({ x, y });
}
},
{ enabled: draggable },
);
useEffect(() => {
if (animated) {
api.start({ x, y, config: config.gentle });
} else {
api.set({ x, y });
}
}, [x, y, animated, api]);
const renderShape = () => {
const halfSize = size / 2;
switch (shape) {
case "diamond":
return React.createElement("polygon", {
points: `${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`,
fill: color,
stroke: "#000",
strokeWidth: "0.5",
});
case "square":
return React.createElement("rect", {
width: size,
height: size,
fill: color,
stroke: "#000",
strokeWidth: "0.5",
rx: "1",
});
case "circle":
default:
return React.createElement("circle", {
cx: halfSize,
cy: halfSize,
r: halfSize,
fill: color,
stroke: "#000",
strokeWidth: "0.5",
});
}
};
const AnimatedG = animated.g;
return React.createElement(
AnimatedG,
{
transform: animated
? undefined
: `translate(${x - size / 2}, ${y - size / 2})`,
style: animated
? {
transform: springX.to(
(x, y) => `translate(${x - size / 2}px, ${y - size / 2}px)`,
),
}
: undefined,
...bind(),
onClick,
style: {
cursor: draggable ? "grab" : onClick ? "pointer" : "default",
},
},
renderShape(),
);
};
const AbacusReact = ({
value = 0,
columns = "auto",
showEmptyColumns = false,
hideInactiveBeads = false,
beadShape = "diamond",
colorScheme = "monochrome",
colorPalette = "default",
scaleFactor = 1,
animated = true,
draggable = false,
onClick,
onValueChange,
}) => {
const { value: currentValue, toggleBead } = useAbacusState(value);
const effectiveColumns = useMemo(() => {
if (columns === "auto") {
return Math.max(1, currentValue.toString().length);
}
return columns;
}, [columns, currentValue]);
const dimensions = useAbacusDimensions(effectiveColumns, scaleFactor);
const beadStates = useMemo(
() => calculateBeadStates(currentValue, effectiveColumns),
[currentValue, effectiveColumns],
);
const margin = 20 * scaleFactor;
const heavenEarthGap = 30 * scaleFactor;
const barY = margin + dimensions.beadSize * 1.5 + heavenEarthGap / 2;
const beadSpacing = 4 * scaleFactor;
const handleBeadClick = useCallback(
(bead) => {
onClick?.(bead);
toggleBead(bead, effectiveColumns);
onValueChange?.(currentValue);
},
[onClick, toggleBead, currentValue, effectiveColumns, onValueChange],
);
const rods = [];
for (let colIndex = 0; colIndex < effectiveColumns; colIndex++) {
const x = margin + colIndex * dimensions.rodSpacing;
rods.push(
React.createElement("line", {
key: `rod-${colIndex}`,
x1: x,
y1: margin,
x2: x,
y2: dimensions.height - margin,
stroke: "#8B4513",
strokeWidth: 3 * scaleFactor,
}),
);
}
const bar = React.createElement("rect", {
x: margin - 10 * scaleFactor,
y: barY,
width: dimensions.width - 2 * margin + 20 * scaleFactor,
height: 2 * scaleFactor,
fill: "#8B4513",
});
const beads = [];
beadStates.forEach((columnBeads, colIndex) => {
columnBeads.forEach((bead, beadIndex) => {
if (hideInactiveBeads && !bead.active) return;
const x = margin + colIndex * dimensions.rodSpacing;
let y;
if (bead.type === "heaven") {
y = margin + dimensions.beadSize / 2;
} else {
const earthStartY =
barY + 2 * scaleFactor + dimensions.beadSize / 2 + beadSpacing;
y =
earthStartY +
bead.position * (dimensions.beadSize + beadSpacing);
}
const color = getBeadColor(
bead,
effectiveColumns,
colorScheme,
colorPalette,
);
beads.push(
React.createElement(Bead, {
key: `bead-${colIndex}-${bead.type}-${beadIndex}`,
bead,
x,
y,
size: dimensions.beadSize,
shape: beadShape,
color,
animated,
draggable,
onClick: () => handleBeadClick(bead),
}),
);
});
});
return React.createElement(
"svg",
{
width: dimensions.width,
height: dimensions.height,
viewBox: `0 0 ${dimensions.width} ${dimensions.height}`,
style: { overflow: "visible" },
},
...rods,
bar,
...beads,
);
};
// Gallery Examples (simplified version)
const GALLERY_EXAMPLES = [
{
id: "basic-5",
title: "Basic Number 5",
subtitle: "Simple representation of 5",
value: 5,
config: {
columns: 1,
beadShape: "diamond",
colorScheme: "monochrome",
scaleFactor: 1,
animated: true,
draggable: true,
},
},
{
id: "colorful-123",
title: "Colorful 123",
subtitle: "Multi-column with place value colors",
value: 123,
config: {
columns: 3,
beadShape: "diamond",
colorScheme: "place-value",
colorPalette: "default",
scaleFactor: 1,
animated: true,
draggable: true,
},
},
{
id: "circles-42",
title: "Circle Beads - 42",
subtitle: "Different bead shape demonstration",
value: 42,
config: {
columns: 2,
beadShape: "circle",
colorScheme: "place-value",
colorPalette: "default",
scaleFactor: 1.2,
animated: true,
draggable: true,
},
},
{
id: "compact-999",
title: "Compact 999",
subtitle: "Square beads with alternating colors",
value: 999,
config: {
columns: 3,
beadShape: "square",
colorScheme: "alternating",
scaleFactor: 0.8,
animated: true,
draggable: true,
},
},
];
const InteractiveAbacusCard = ({ example }) => {
const [currentValue, setCurrentValue] = useState(example.value);
const [clickCount, setClickCount] = useState(0);
const dimensions = useAbacusDimensions(
example.config.columns,
example.config.scaleFactor || 1,
);
const handleValueChange = useCallback((newValue) => {
setCurrentValue(newValue);
}, []);
const handleBeadClick = useCallback((bead) => {
setClickCount((prev) => prev + 1);
}, []);
const resetValue = useCallback(() => {
setCurrentValue(example.value);
setClickCount(0);
}, [example.value]);
return React.createElement(
"div",
{
style: {
background: "white",
borderRadius: "12px",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
overflow: "hidden",
transition: "all 0.3s ease",
margin: "20px",
},
},
React.createElement(
"div",
{
style: {
padding: "20px",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
},
},
React.createElement(
"div",
null,
React.createElement(
"h3",
{
style: {
margin: "0 0 5px 0",
color: "#2c3e50",
fontSize: "1.3rem",
},
},
example.title,
),
React.createElement(
"p",
{ style: { margin: 0, color: "#666", fontSize: "0.9rem" } },
example.subtitle,
),
),
React.createElement(
"div",
{
style: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "5px",
},
},
React.createElement(
"div",
{ style: { fontSize: "0.85rem", color: "#666" } },
`Value: ${currentValue}`,
),
React.createElement(
"div",
{ style: { fontSize: "0.85rem", color: "#666" } },
`Clicks: ${clickCount}`,
),
React.createElement(
"button",
{
onClick: resetValue,
style: {
background: "#3498db",
color: "white",
border: "none",
borderRadius: "4px",
padding: "5px 10px",
cursor: "pointer",
fontSize: "1.2rem",
},
},
"↻",
),
),
),
React.createElement(
"div",
{
style: {
padding: "30px",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "200px",
background: "#fafafa",
},
},
React.createElement(
"div",
{
style: {
width: `${dimensions.width}px`,
height: `${dimensions.height}px`,
border: "2px solid rgba(0,0,0,0.1)",
borderRadius: "8px",
background: "white",
padding: "10px",
},
},
React.createElement(AbacusReact, {
value: currentValue,
...example.config,
onClick: handleBeadClick,
onValueChange: handleValueChange,
}),
),
),
);
};
const InteractiveGallery = () => {
const [totalClicks, setTotalClicks] = useState(0);
return React.createElement(
"div",
{
style: {
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
lineHeight: 1.6,
color: "#333",
background: "#f5f5f5",
padding: "20px",
minHeight: "100vh",
},
},
React.createElement(
"div",
{
style: {
maxWidth: "1400px",
margin: "0 auto",
},
},
React.createElement(
"div",
{
style: {
textAlign: "center",
marginBottom: "40px",
padding: "40px 20px",
background: "white",
borderRadius: "12px",
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
},
},
React.createElement(
"h1",
{
style: {
fontSize: "2.5rem",
marginBottom: "10px",
color: "#2c3e50",
},
},
"🧮 Interactive Soroban Gallery",
),
React.createElement(
"p",
{
style: {
fontSize: "1.1rem",
color: "#666",
marginBottom: "20px",
},
},
"Click and drag the beads to explore how a Japanese abacus works!",
),
),
React.createElement(
"div",
{
style: {
background: "#e8f4fd",
border: "1px solid #bee5eb",
borderRadius: "8px",
padding: "20px",
marginBottom: "20px",
textAlign: "center",
},
},
React.createElement(
"h3",
{
style: {
color: "#0c5460",
marginBottom: "10px",
},
},
"🎯 How to Interact",
),
React.createElement(
"p",
{
style: {
color: "#0c5460",
margin: 0,
},
},
"Click beads to toggle their positions • Drag beads for tactile feedback • Reset button restores original values",
),
),
React.createElement(
"div",
{
style: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(400px, 1fr))",
gap: "0px",
},
},
...GALLERY_EXAMPLES.map((example) =>
React.createElement(InteractiveAbacusCard, {
key: example.id,
example,
}),
),
),
),
);
};
// Hide loading and render gallery
document.getElementById("loading").style.display = "none";
ReactDOM.render(
React.createElement(InteractiveGallery),
document.getElementById("root"),
);
</script>
</body>
</html>