feat: add function-based custom bead rendering and HTTP status code easter eggs

Add dynamic custom bead rendering system that allows beads to change appearance
based on their context (active state, position, place value, type, etc.).

Custom Bead Features:
- Add emoji-function, image-function, and svg-function types
- Functions receive CustomBeadContext with bead state and style info
- Support for dynamic rendering based on: active state, position, place value,
  bead type (heaven/earth), color, and size
- Enables creative visualizations like traffic lights, themed symbols, etc.

404 Page Easter Eggs:
- Create interactive 404 page with manipulable abacus
- Add 14 HTTP status code easter eggs (200, 201, 301, 400, 401, 403, 418,
  420, 451, 500, 503, 666, 777, 911)
- Each code triggers site-wide custom bead transformation
- Use function-based rendering for variety (different emojis per bead
  position/state)
- Easter eggs persist until page reload via global AbacusDisplayContext

Storybook Documentation:
- Add comprehensive custom bead stories showing static and function-based usage
- Include examples: active/inactive states, heaven/earth types, place value
  colors, traffic lights, color theming
- Document CustomBeadContext API and usage patterns

Technical Implementation:
- Extend CustomBeadContent union type in AbacusContext
- Update AbacusStaticBead and AbacusAnimatedBead to handle function types
- Pass bead context (type, value, active, position, placeValue, color, size)
  to custom render functions
- Maintain consistency across static (SSR) and animated (client) rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-08 14:25:47 -06:00
parent 8407b070f9
commit fde5ae9164
8 changed files with 1025 additions and 16 deletions

View File

@ -0,0 +1,294 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import type { CustomBeadContent } from '@soroban/abacus-react'
import { AbacusReact, useAbacusDisplay } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../styled-system/css'
import { stack } from '../../styled-system/patterns'
// HTTP Status Code Easter Eggs with dynamic bead rendering
const STATUS_CODE_EASTER_EGGS: Record<
number,
{ customBeadContent: CustomBeadContent; message: string }
> = {
200: {
customBeadContent: { type: 'emoji-function', value: (bead) => (bead.active ? '✅' : '⭕') },
message: "Everything's counting perfectly!",
},
201: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.type === 'heaven' ? '🥚' : '🐣'),
},
message: 'Something new has been counted into existence!',
},
301: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.active ? '🚚' : '📦'),
},
message: 'These numbers have permanently relocated!',
},
400: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.active ? '❌' : '❓'),
},
message: "Those numbers don't make sense!",
},
401: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.active ? '🔒' : '🔑'),
},
message: 'These numbers are classified!',
},
403: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.type === 'heaven' ? '🚫' : '⛔'),
},
message: "You're not allowed to count these numbers!",
},
418: {
customBeadContent: { type: 'emoji', value: '🫖' },
message: "Perhaps you're pouring in the wrong direction?",
},
420: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const emojis = ['🌿', '🍃', '🌱', '🪴']
return emojis[bead.position % emojis.length] || '🌿'
},
},
message: 'Whoa dude, these numbers are like... relative, man',
},
451: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => (bead.active ? '🤐' : '▓'),
},
message: '[REDACTED] - This number has been removed by the Ministry of Mathematics',
},
500: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const fireEmojis = ['🔥', '💥', '⚠️']
return bead.active ? fireEmojis[bead.position % fireEmojis.length] || '🔥' : '💨'
},
},
message: 'The abacus has caught fire!',
},
503: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const tools = ['🔧', '🔨', '🪛', '⚙️']
return bead.active ? tools[bead.placeValue % tools.length] || '🔧' : '⚪'
},
},
message: "Pardon our dust, we're upgrading the beads!",
},
666: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const demons = ['😈', '👹', '👺', '💀']
return bead.active ? demons[bead.position % demons.length] || '😈' : '🔥'
},
},
message: 'Your soul now belongs to arithmetic!',
},
777: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const lucky = ['🎰', '🍀', '💰', '🎲', '⭐']
return bead.active ? lucky[bead.placeValue % lucky.length] || '🎰' : '⚪'
},
},
message: "Jackpot! You've mastered the soroban!",
},
911: {
customBeadContent: {
type: 'emoji-function',
value: (bead) => {
const emergency = ['🚨', '🚑', '🚒', '👮']
return bead.active ? emergency[bead.position % emergency.length] || '🚨' : '⚫'
},
},
message: 'EMERGENCY: Someone needs help with their math homework!',
},
}
export default function NotFound() {
const [abacusValue, setAbacusValue] = useState(404)
const [activeEasterEgg, setActiveEasterEgg] = useState<number | null>(null)
const { updateConfig, resetToDefaults } = useAbacusDisplay()
// Easter egg activation - update global abacus config when special codes are entered
useEffect(() => {
const easterEgg = STATUS_CODE_EASTER_EGGS[abacusValue]
if (easterEgg && activeEasterEgg !== abacusValue) {
setActiveEasterEgg(abacusValue)
// Update global abacus display config to use custom beads
// This affects ALL abaci rendered in the app until page reload!
updateConfig({
beadShape: 'custom',
customBeadContent: easterEgg.customBeadContent,
})
// Store active easter egg in window so it persists across navigation
;(window as any).__easterEggMode = abacusValue
} else if (!easterEgg && activeEasterEgg !== null) {
// User changed away from an easter egg code - reset to defaults
setActiveEasterEgg(null)
resetToDefaults()
;(window as any).__easterEggMode = null
}
}, [abacusValue, activeEasterEgg, updateConfig, resetToDefaults])
return (
<PageWithNav>
<div
className={css({
minHeight: 'calc(100vh - 64px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'bg.canvas',
padding: '2rem',
})}
>
<div
className={stack({
gap: '2rem',
alignItems: 'center',
textAlign: 'center',
maxWidth: '600px',
})}
>
{/* Interactive Abacus */}
<div
className={css({
transform: 'scale(1.2)',
transformOrigin: 'center',
position: 'relative',
})}
>
<AbacusReact
value={abacusValue}
columns={3}
showNumbers={false}
onValueChange={setAbacusValue}
/>
</div>
{/* Main message */}
<div className={stack({ gap: '1rem' })}>
<h1
className={css({
fontSize: '3rem',
fontWeight: 'bold',
color: 'text.primary',
lineHeight: '1.2',
})}
>
{activeEasterEgg
? STATUS_CODE_EASTER_EGGS[activeEasterEgg].message
: "Oops! We've lost count."}
</h1>
</div>
{/* Navigation links */}
<div
className={css({
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
justifyContent: 'center',
})}
>
<Link
href="/"
className={css({
px: '2rem',
py: '1rem',
bg: 'blue.500',
color: 'white',
borderRadius: '0.5rem',
fontWeight: 'semibold',
textDecoration: 'none',
transition: 'all 0.2s',
_hover: {
bg: 'blue.600',
transform: 'translateY(-2px)',
},
})}
>
Home
</Link>
<Link
href="/games"
className={css({
px: '2rem',
py: '1rem',
bg: 'purple.500',
color: 'white',
borderRadius: '0.5rem',
fontWeight: 'semibold',
textDecoration: 'none',
transition: 'all 0.2s',
_hover: {
bg: 'purple.600',
transform: 'translateY(-2px)',
},
})}
>
Games
</Link>
<Link
href="/create"
className={css({
px: '2rem',
py: '1rem',
bg: 'green.500',
color: 'white',
borderRadius: '0.5rem',
fontWeight: 'semibold',
textDecoration: 'none',
transition: 'all 0.2s',
_hover: {
bg: 'green.600',
transform: 'translateY(-2px)',
},
})}
>
Create
</Link>
</div>
{/* Easter egg hint */}
<p
className={css({
fontSize: '0.875rem',
color: 'text.secondary',
opacity: 0.6,
marginTop: '2rem',
})}
>
Try other HTTP status codes...
</p>
</div>
</div>
</PageWithNav>
)
}

View File

@ -31,6 +31,7 @@ import { useSpring, animated, to } from "@react-spring/web";
import { useDrag } from "@use-gesture/react"; import { useDrag } from "@use-gesture/react";
import type { BeadComponentProps } from "./AbacusSVGRenderer"; import type { BeadComponentProps } from "./AbacusSVGRenderer";
import type { BeadConfig } from "./AbacusReact"; import type { BeadConfig } from "./AbacusReact";
import type { CustomBeadContext } from "./AbacusContext";
interface AnimatedBeadProps extends BeadComponentProps { interface AnimatedBeadProps extends BeadComponentProps {
// Animation controls // Animation controls
@ -65,6 +66,7 @@ export function AbacusAnimatedBead({
shape, shape,
color, color,
hideInactiveBeads, hideInactiveBeads,
customBeadContent,
customStyle, customStyle,
onClick, onClick,
onMouseEnter, onMouseEnter,
@ -203,16 +205,6 @@ export function AbacusAnimatedBead({
const renderShape = () => { const renderShape = () => {
const halfSize = size / 2; const halfSize = size / 2;
// Determine fill - use gradient for realistic mode, otherwise use color
let fillValue = customStyle?.fill || color;
if (enhanced3d === "realistic" && columnIndex !== undefined) {
if (bead.type === "heaven") {
fillValue = `url(#bead-gradient-${columnIndex}-heaven)`;
} else {
fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`;
}
}
// Calculate opacity based on state and settings // Calculate opacity based on state and settings
let opacity: number; let opacity: number;
if (customStyle?.opacity !== undefined) { if (customStyle?.opacity !== undefined) {
@ -232,6 +224,105 @@ export function AbacusAnimatedBead({
opacity = 1; opacity = 1;
} }
// Custom bead content (emoji, image, or SVG)
if (shape === "custom" && customBeadContent) {
// Build context for function-based custom beads
const beadContext: CustomBeadContext = {
type: bead.type,
value: bead.value,
active: bead.active,
position: bead.position,
placeValue: bead.placeValue,
color,
size,
};
switch (customBeadContent.type) {
case "emoji":
return (
<text
x={halfSize}
y={halfSize}
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 1.5}
opacity={opacity}
style={{ userSelect: "none" }}
>
{customBeadContent.value}
</text>
);
case "emoji-function": {
const emoji = customBeadContent.value(beadContext);
return (
<text
x={halfSize}
y={halfSize}
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 1.5}
opacity={opacity}
style={{ userSelect: "none" }}
>
{emoji}
</text>
);
}
case "image":
return (
<image
href={customBeadContent.url}
x={0}
y={0}
width={customBeadContent.width || size}
height={customBeadContent.height || size}
opacity={opacity}
preserveAspectRatio="xMidYMid meet"
/>
);
case "image-function": {
const imageProps = customBeadContent.value(beadContext);
return (
<image
href={imageProps.url}
x={0}
y={0}
width={imageProps.width || size}
height={imageProps.height || size}
opacity={opacity}
preserveAspectRatio="xMidYMid meet"
/>
);
}
case "svg":
return (
<g
opacity={opacity}
dangerouslySetInnerHTML={{ __html: customBeadContent.content }}
/>
);
case "svg-function": {
const svgContent = customBeadContent.value(beadContext);
return (
<g
opacity={opacity}
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}
}
}
// Determine fill - use gradient for realistic mode, otherwise use color
let fillValue = customStyle?.fill || color;
if (enhanced3d === "realistic" && columnIndex !== undefined) {
if (bead.type === "heaven") {
fillValue = `url(#bead-gradient-${columnIndex}-heaven)`;
} else {
fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`;
}
}
const stroke = customStyle?.stroke || "#000"; const stroke = customStyle?.stroke || "#000";
const strokeWidth = customStyle?.strokeWidth || 0.5; const strokeWidth = customStyle?.strokeWidth || 0.5;
@ -276,6 +367,7 @@ export function AbacusAnimatedBead({
// Calculate offsets for shape positioning // Calculate offsets for shape positioning
const getXOffset = () => { const getXOffset = () => {
if (shape === "custom") return size / 2;
return shape === "diamond" ? size * 0.7 : size / 2; return shape === "diamond" ? size * 0.7 : size / 2;
}; };

View File

@ -15,7 +15,31 @@ export type ColorScheme =
| "place-value" | "place-value"
| "heaven-earth" | "heaven-earth"
| "alternating"; | "alternating";
export type BeadShape = "diamond" | "circle" | "square"; export type BeadShape = "diamond" | "circle" | "square" | "custom";
// Bead info passed to custom bead functions
export interface CustomBeadContext {
// Bead identity
type: "heaven" | "earth";
value: number;
active: boolean;
position: number; // 0-based position within its type group
placeValue: number; // 0=ones, 1=tens, 2=hundreds, etc.
// Style context - so custom beads can match abacus theme
color: string; // The color that would be used for this bead
size: number; // Bead size in pixels
}
// Custom bead content types
export type CustomBeadContent =
| { type: "emoji"; value: string } // e.g., { type: "emoji", value: "🫖" }
| { type: "emoji-function"; value: (bead: CustomBeadContext) => string } // e.g., { type: "emoji-function", value: (bead) => bead.active ? "✅" : "⭕" }
| { type: "image"; url: string; width?: number; height?: number } // e.g., { type: "image", url: "/star.png" }
| { type: "image-function"; value: (bead: CustomBeadContext) => { url: string; width?: number; height?: number } } // Dynamic images
| { type: "svg"; content: string } // e.g., { type: "svg", content: "<path d='...' />" }
| { type: "svg-function"; value: (bead: CustomBeadContext) => string }; // Dynamic SVG
export type ColorPalette = export type ColorPalette =
| "default" | "default"
| "colorblind" | "colorblind"
@ -26,6 +50,7 @@ export type ColorPalette =
export interface AbacusDisplayConfig { export interface AbacusDisplayConfig {
colorScheme: ColorScheme; colorScheme: ColorScheme;
beadShape: BeadShape; beadShape: BeadShape;
customBeadContent?: CustomBeadContent; // Custom bead content when beadShape is "custom"
colorPalette: ColorPalette; colorPalette: ColorPalette;
hideInactiveBeads: boolean; hideInactiveBeads: boolean;
coloredNumerals: boolean; coloredNumerals: boolean;
@ -80,9 +105,12 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
].includes(parsed.colorScheme) ].includes(parsed.colorScheme)
? parsed.colorScheme ? parsed.colorScheme
: DEFAULT_CONFIG.colorScheme, : DEFAULT_CONFIG.colorScheme,
beadShape: ["diamond", "circle", "square"].includes(parsed.beadShape) beadShape: ["diamond", "circle", "square", "custom"].includes(
parsed.beadShape,
)
? parsed.beadShape ? parsed.beadShape
: DEFAULT_CONFIG.beadShape, : DEFAULT_CONFIG.beadShape,
customBeadContent: parsed.customBeadContent || undefined,
colorPalette: [ colorPalette: [
"default", "default",
"colorblind", "colorblind",

View File

@ -0,0 +1,491 @@
import type { Meta, StoryObj } from "@storybook/react";
import AbacusReact from "./AbacusReact";
const meta: Meta<typeof AbacusReact> = {
title: "Soroban/AbacusReact/Custom Beads",
component: AbacusReact,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Custom bead content allows you to replace standard bead shapes (diamond, circle, square) with emojis, images, or custom SVG content. Perfect for themed visualizations or fun educational contexts.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof AbacusReact>;
/**
* Emoji Beads - Teapot Example
*
* Replace all beads with emoji characters! Perfect for fun themes and easter eggs.
*/
export const EmojiBeads_Teapot: Story = {
args: {
value: 418,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🫖",
},
showNumbers: true,
scaleFactor: 1.5,
},
parameters: {
docs: {
description: {
story:
"HTTP 418 'I'm a teapot' represented with teapot emoji beads! Set `beadShape='custom'` and provide `customBeadContent` with type 'emoji'.",
},
},
},
};
/**
* Emoji Beads - Star Example
*/
export const EmojiBeads_Stars: Story = {
args: {
value: 555,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "⭐",
},
showNumbers: true,
colorScheme: "place-value",
},
parameters: {
docs: {
description: {
story: "Stars for rating systems or achievements!",
},
},
},
};
/**
* Emoji Beads - Fruit Counter
*/
export const EmojiBeads_Fruit: Story = {
args: {
value: 123,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🍎",
},
showNumbers: true,
hideInactiveBeads: true,
},
parameters: {
docs: {
description: {
story: "Count apples, oranges, or any fruit! Great for early math education.",
},
},
},
};
/**
* Emoji Beads - Coins
*/
export const EmojiBeads_Coins: Story = {
args: {
value: 999,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🪙",
},
showNumbers: true,
scaleFactor: 1.3,
},
parameters: {
docs: {
description: {
story: "Perfect for teaching money and currency concepts!",
},
},
},
};
/**
* Emoji Beads - Hearts
*/
export const EmojiBeads_Hearts: Story = {
args: {
value: 143, // "I Love You" in pager code
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "❤️",
},
showNumbers: true,
},
parameters: {
docs: {
description: {
story: "Hearts for Valentine's Day or expressing love! 143 = 'I Love You'.",
},
},
},
};
/**
* Emoji Beads - Fire
*/
export const EmojiBeads_Fire: Story = {
args: {
value: 100,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🔥",
},
showNumbers: true,
interactive: true,
animated: true,
},
parameters: {
docs: {
description: {
story: "On fire! Perfect for streaks or hot topics.",
},
},
},
};
/**
* Emoji Beads - Dice
*/
export const EmojiBeads_Dice: Story = {
args: {
value: 666,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🎲",
},
showNumbers: true,
},
parameters: {
docs: {
description: {
story: "Dice for probability and gaming applications!",
},
},
},
};
/**
* Emoji Beads - Abacus Inception!
*
* An abacus made of tiny abacus beads! Meta abacus counting.
*/
export const EmojiBeads_AbacusInception: Story = {
args: {
value: 1234,
columns: 4,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🧮",
},
showNumbers: true,
scaleFactor: 1.4,
},
parameters: {
docs: {
description: {
story:
"Abacus-ception! An abacus made of tiny abacus emojis. We need to go deeper... 🧮",
},
},
},
};
/**
* Interactive Custom Beads
*
* Custom beads work with all interactive features!
*/
export const Interactive_CustomBeads: Story = {
args: {
value: 42,
columns: 2,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "🎯",
},
showNumbers: true,
interactive: true,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story:
"Custom beads support all interactivity - click, drag, and animate just like standard shapes!",
},
},
},
};
/**
* Image Beads Example
*
* Use custom images as beads! Images scale to fit bead size.
*/
export const ImageBeads_Example: Story = {
args: {
value: 25,
columns: 2,
beadShape: "custom",
customBeadContent: {
type: "image",
url: "https://via.placeholder.com/50/ff6b35/ffffff?text=★",
},
showNumbers: true,
scaleFactor: 1.5,
},
parameters: {
docs: {
description: {
story:
"Use image URLs for custom bead graphics. Images automatically scale to fit the bead size.",
},
},
},
};
/**
* Comparison: Standard vs Custom Beads
*/
export const Comparison_StandardVsCustom: Story = {
render: () => (
<div style={{ display: "flex", gap: "40px", flexWrap: "wrap" }}>
<div style={{ textAlign: "center" }}>
<h3>Standard Diamond Beads</h3>
<AbacusReact value={123} columns={3} beadShape="diamond" showNumbers />
</div>
<div style={{ textAlign: "center" }}>
<h3>Custom Emoji Beads (🫖)</h3>
<AbacusReact
value={123}
columns={3}
beadShape="custom"
customBeadContent={{ type: "emoji", value: "🫖" }}
showNumbers
/>
</div>
<div style={{ textAlign: "center" }}>
<h3>Custom Emoji Beads ()</h3>
<AbacusReact
value={123}
columns={3}
beadShape="custom"
customBeadContent={{ type: "emoji", value: "⭐" }}
showNumbers
/>
</div>
</div>
),
parameters: {
docs: {
description: {
story: "Side-by-side comparison of standard and custom bead shapes.",
},
},
},
};
/**
* Custom Beads with Theme Styling
*/
export const CustomBeads_WithThemes: Story = {
args: {
value: 789,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji",
value: "💎",
},
showNumbers: true,
colorScheme: "place-value",
hideInactiveBeads: true,
},
parameters: {
docs: {
description: {
story:
"Custom beads work seamlessly with all color schemes and styling options!",
},
},
},
};
/**
* Function-Based Custom Beads - Active/Inactive States
*
* Use a function to render different emojis based on bead state!
*/
export const Function_ActiveInactive: Story = {
args: {
value: 123,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji-function",
value: (bead) => (bead.active ? "✅" : "⭕"),
},
showNumbers: true,
},
parameters: {
docs: {
description: {
story:
"Different emojis for active (✅) vs inactive (⭕) beads! The function receives bead context and returns the appropriate emoji.",
},
},
},
};
/**
* Function-Based Custom Beads - Heaven vs Earth
*
* Different emojis based on bead type!
*/
export const Function_HeavenEarth: Story = {
args: {
value: 567,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji-function",
value: (bead) => (bead.type === "heaven" ? "☁️" : "🌍"),
},
showNumbers: true,
},
parameters: {
docs: {
description: {
story:
"Heaven beads (☁️) and Earth beads (🌍) with different emojis based on bead type!",
},
},
},
};
/**
* Function-Based Custom Beads - Place Value Colors
*
* Use different emojis per column (place value)!
*/
export const Function_PlaceValue: Story = {
args: {
value: 999,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji-function",
value: (bead) => {
const emojis = ["🟢", "🔵", "🔴", "🟡", "🟣"];
return emojis[bead.placeValue] || "⚪";
},
},
showNumbers: true,
},
parameters: {
docs: {
description: {
story:
"Different colored circles for each place value (ones=green, tens=blue, hundreds=red)!",
},
},
},
};
/**
* Function-Based Custom Beads - Traffic Light Pattern
*
* Complex logic: traffic lights based on active state AND position!
*/
export const Function_TrafficLights: Story = {
args: {
value: 234,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji-function",
value: (bead) => {
if (!bead.active) return "⚫";
if (bead.type === "heaven") return "🔴"; // Heaven beads = red
// Earth beads cycle through traffic light colors by position
const colors = ["🟢", "🟡", "🔴", "🟠"];
return colors[bead.position] || "⚪";
},
},
showNumbers: true,
interactive: true,
},
parameters: {
docs: {
description: {
story:
"Traffic light pattern! Heaven beads are red, earth beads cycle through colors by position. Inactive beads are dark. Try clicking to see it change!",
},
},
},
};
/**
* Function-Based Custom Beads - Themed by Color
*
* Use the bead's color property to choose themed emojis!
*/
export const Function_ColorThemed: Story = {
args: {
value: 456,
columns: 3,
beadShape: "custom",
customBeadContent: {
type: "emoji-function",
value: (bead) => {
// Use the color to determine emoji theme
if (bead.color.includes("red") || bead.color.includes("f00"))
return "🍎";
if (bead.color.includes("blue") || bead.color.includes("00f"))
return "🔵";
if (bead.color.includes("green") || bead.color.includes("0f0"))
return "🍏";
return bead.active ? "⭐" : "⚪";
},
},
showNumbers: true,
colorScheme: "place-value",
},
parameters: {
docs: {
description: {
story:
"Emojis chosen based on the bead's color! Red beads = apples, blue = circles, green = green apples.",
},
},
},
};

View File

@ -267,7 +267,8 @@ export interface AbacusConfig {
columns?: number | "auto"; columns?: number | "auto";
showEmptyColumns?: boolean; showEmptyColumns?: boolean;
hideInactiveBeads?: boolean; hideInactiveBeads?: boolean;
beadShape?: "diamond" | "square" | "circle"; beadShape?: "diamond" | "square" | "circle" | "custom";
customBeadContent?: import("./AbacusContext").CustomBeadContent; // Custom emoji, image, or SVG
colorScheme?: "monochrome" | "place-value" | "alternating" | "heaven-earth"; colorScheme?: "monochrome" | "place-value" | "alternating" | "heaven-earth";
colorPalette?: "default" | "colorblind" | "mnemonic" | "grayscale" | "nature"; colorPalette?: "default" | "colorblind" | "mnemonic" | "grayscale" | "nature";
scaleFactor?: number; scaleFactor?: number;
@ -1594,6 +1595,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
showEmptyColumns = false, showEmptyColumns = false,
hideInactiveBeads, hideInactiveBeads,
beadShape, beadShape,
customBeadContent,
colorScheme, colorScheme,
colorPalette, colorPalette,
scaleFactor, scaleFactor,
@ -1643,6 +1645,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
const finalConfig = { const finalConfig = {
hideInactiveBeads: hideInactiveBeads ?? contextConfig.hideInactiveBeads, hideInactiveBeads: hideInactiveBeads ?? contextConfig.hideInactiveBeads,
beadShape: beadShape ?? contextConfig.beadShape, beadShape: beadShape ?? contextConfig.beadShape,
customBeadContent: customBeadContent ?? contextConfig.customBeadContent,
colorScheme: colorScheme ?? contextConfig.colorScheme, colorScheme: colorScheme ?? contextConfig.colorScheme,
colorPalette: colorPalette ?? contextConfig.colorPalette, colorPalette: colorPalette ?? contextConfig.colorPalette,
scaleFactor: scaleFactor ?? contextConfig.scaleFactor, scaleFactor: scaleFactor ?? contextConfig.scaleFactor,
@ -2308,6 +2311,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
dimensions={standardDims} dimensions={standardDims}
scaleFactor={finalConfig.scaleFactor} scaleFactor={finalConfig.scaleFactor}
beadShape={finalConfig.beadShape} beadShape={finalConfig.beadShape}
customBeadContent={finalConfig.customBeadContent}
colorScheme={finalConfig.colorScheme} colorScheme={finalConfig.colorScheme}
colorPalette={finalConfig.colorPalette} colorPalette={finalConfig.colorPalette}
hideInactiveBeads={finalConfig.hideInactiveBeads} hideInactiveBeads={finalConfig.hideInactiveBeads}

View File

@ -54,9 +54,10 @@ export interface BeadComponentProps {
x: number; x: number;
y: number; y: number;
size: number; size: number;
shape: "circle" | "diamond" | "square"; shape: "circle" | "diamond" | "square" | "custom";
color: string; color: string;
hideInactiveBeads: boolean; hideInactiveBeads: boolean;
customBeadContent?: import("./AbacusContext").CustomBeadContent;
customStyle?: { customStyle?: {
fill?: string; fill?: string;
stroke?: string; stroke?: string;
@ -84,7 +85,8 @@ export interface AbacusSVGRendererProps {
scaleFactor?: number; scaleFactor?: number;
// Appearance // Appearance
beadShape: "circle" | "diamond" | "square"; beadShape: "circle" | "diamond" | "square" | "custom";
customBeadContent?: import("./AbacusContext").CustomBeadContent;
colorScheme: string; colorScheme: string;
colorPalette: string; colorPalette: string;
hideInactiveBeads: boolean; hideInactiveBeads: boolean;
@ -141,6 +143,7 @@ export function AbacusSVGRenderer({
dimensions, dimensions,
scaleFactor = 1, scaleFactor = 1,
beadShape, beadShape,
customBeadContent,
colorScheme, colorScheme,
colorPalette, colorPalette,
hideInactiveBeads, hideInactiveBeads,
@ -405,6 +408,7 @@ export function AbacusSVGRenderer({
y: position.y, y: position.y,
size: beadSize, size: beadSize,
shape: beadShape, shape: beadShape,
customBeadContent,
color, color,
hideInactiveBeads, hideInactiveBeads,
customStyle, customStyle,

View File

@ -4,15 +4,17 @@
*/ */
import type { BeadConfig, BeadStyle } from "./AbacusReact"; import type { BeadConfig, BeadStyle } from "./AbacusReact";
import type { CustomBeadContent, CustomBeadContext } from "./AbacusContext";
export interface StaticBeadProps { export interface StaticBeadProps {
bead: BeadConfig; bead: BeadConfig;
x: number; x: number;
y: number; y: number;
size: number; size: number;
shape: "diamond" | "square" | "circle"; shape: "diamond" | "square" | "circle" | "custom";
color: string; color: string;
customStyle?: BeadStyle; customStyle?: BeadStyle;
customBeadContent?: CustomBeadContent;
hideInactiveBeads?: boolean; hideInactiveBeads?: boolean;
} }
@ -24,6 +26,7 @@ export function AbacusStaticBead({
shape, shape,
color, color,
customStyle, customStyle,
customBeadContent,
hideInactiveBeads = false, hideInactiveBeads = false,
}: StaticBeadProps) { }: StaticBeadProps) {
// Don't render inactive beads if hideInactiveBeads is true // Don't render inactive beads if hideInactiveBeads is true
@ -39,6 +42,7 @@ export function AbacusStaticBead({
// Calculate offset based on shape (matching AbacusReact positioning) // Calculate offset based on shape (matching AbacusReact positioning)
const getXOffset = () => { const getXOffset = () => {
if (shape === "custom") return halfSize;
return shape === "diamond" ? size * 0.7 : halfSize; return shape === "diamond" ? size * 0.7 : halfSize;
}; };
@ -49,6 +53,96 @@ export function AbacusStaticBead({
const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`; const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`;
const renderShape = () => { const renderShape = () => {
// Custom bead content (emoji, image, or SVG)
if (shape === "custom" && customBeadContent) {
// Build context for function-based custom beads
const beadContext: CustomBeadContext = {
type: bead.type,
value: bead.value,
active: bead.active,
position: bead.position,
placeValue: bead.placeValue,
color,
size,
};
switch (customBeadContent.type) {
case "emoji":
return (
<text
x={halfSize}
y={halfSize}
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 1.5}
opacity={opacity}
style={{ userSelect: "none" }}
>
{customBeadContent.value}
</text>
);
case "emoji-function": {
const emoji = customBeadContent.value(beadContext);
return (
<text
x={halfSize}
y={halfSize}
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 1.5}
opacity={opacity}
style={{ userSelect: "none" }}
>
{emoji}
</text>
);
}
case "image":
return (
<image
href={customBeadContent.url}
x={0}
y={0}
width={customBeadContent.width || size}
height={customBeadContent.height || size}
opacity={opacity}
preserveAspectRatio="xMidYMid meet"
/>
);
case "image-function": {
const imageProps = customBeadContent.value(beadContext);
return (
<image
href={imageProps.url}
x={0}
y={0}
width={imageProps.width || size}
height={imageProps.height || size}
opacity={opacity}
preserveAspectRatio="xMidYMid meet"
/>
);
}
case "svg":
return (
<g
opacity={opacity}
dangerouslySetInnerHTML={{ __html: customBeadContent.content }}
/>
);
case "svg-function": {
const svgContent = customBeadContent.value(beadContext);
return (
<g
opacity={opacity}
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}
}
}
// Standard shapes
switch (shape) { switch (shape) {
case "diamond": case "diamond":
return ( return (

View File

@ -26,6 +26,8 @@ export type {
ColorScheme, ColorScheme,
BeadShape, BeadShape,
ColorPalette, ColorPalette,
CustomBeadContent,
CustomBeadContext,
AbacusDisplayConfig, AbacusDisplayConfig,
AbacusDisplayContextType, AbacusDisplayContextType,
} from "./AbacusContext"; } from "./AbacusContext";