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:
parent
8407b070f9
commit
fde5ae9164
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import { useSpring, animated, to } from "@react-spring/web";
|
|||
import { useDrag } from "@use-gesture/react";
|
||||
import type { BeadComponentProps } from "./AbacusSVGRenderer";
|
||||
import type { BeadConfig } from "./AbacusReact";
|
||||
import type { CustomBeadContext } from "./AbacusContext";
|
||||
|
||||
interface AnimatedBeadProps extends BeadComponentProps {
|
||||
// Animation controls
|
||||
|
|
@ -65,6 +66,7 @@ export function AbacusAnimatedBead({
|
|||
shape,
|
||||
color,
|
||||
hideInactiveBeads,
|
||||
customBeadContent,
|
||||
customStyle,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
|
|
@ -203,16 +205,6 @@ export function AbacusAnimatedBead({
|
|||
const renderShape = () => {
|
||||
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
|
||||
let opacity: number;
|
||||
if (customStyle?.opacity !== undefined) {
|
||||
|
|
@ -232,6 +224,105 @@ export function AbacusAnimatedBead({
|
|||
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 strokeWidth = customStyle?.strokeWidth || 0.5;
|
||||
|
||||
|
|
@ -276,6 +367,7 @@ export function AbacusAnimatedBead({
|
|||
|
||||
// Calculate offsets for shape positioning
|
||||
const getXOffset = () => {
|
||||
if (shape === "custom") return size / 2;
|
||||
return shape === "diamond" ? size * 0.7 : size / 2;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,31 @@ export type ColorScheme =
|
|||
| "place-value"
|
||||
| "heaven-earth"
|
||||
| "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 =
|
||||
| "default"
|
||||
| "colorblind"
|
||||
|
|
@ -26,6 +50,7 @@ export type ColorPalette =
|
|||
export interface AbacusDisplayConfig {
|
||||
colorScheme: ColorScheme;
|
||||
beadShape: BeadShape;
|
||||
customBeadContent?: CustomBeadContent; // Custom bead content when beadShape is "custom"
|
||||
colorPalette: ColorPalette;
|
||||
hideInactiveBeads: boolean;
|
||||
coloredNumerals: boolean;
|
||||
|
|
@ -80,9 +105,12 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
|
|||
].includes(parsed.colorScheme)
|
||||
? parsed.colorScheme
|
||||
: DEFAULT_CONFIG.colorScheme,
|
||||
beadShape: ["diamond", "circle", "square"].includes(parsed.beadShape)
|
||||
beadShape: ["diamond", "circle", "square", "custom"].includes(
|
||||
parsed.beadShape,
|
||||
)
|
||||
? parsed.beadShape
|
||||
: DEFAULT_CONFIG.beadShape,
|
||||
customBeadContent: parsed.customBeadContent || undefined,
|
||||
colorPalette: [
|
||||
"default",
|
||||
"colorblind",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -267,7 +267,8 @@ export interface AbacusConfig {
|
|||
columns?: number | "auto";
|
||||
showEmptyColumns?: 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";
|
||||
colorPalette?: "default" | "colorblind" | "mnemonic" | "grayscale" | "nature";
|
||||
scaleFactor?: number;
|
||||
|
|
@ -1594,6 +1595,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
|||
showEmptyColumns = false,
|
||||
hideInactiveBeads,
|
||||
beadShape,
|
||||
customBeadContent,
|
||||
colorScheme,
|
||||
colorPalette,
|
||||
scaleFactor,
|
||||
|
|
@ -1643,6 +1645,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
|||
const finalConfig = {
|
||||
hideInactiveBeads: hideInactiveBeads ?? contextConfig.hideInactiveBeads,
|
||||
beadShape: beadShape ?? contextConfig.beadShape,
|
||||
customBeadContent: customBeadContent ?? contextConfig.customBeadContent,
|
||||
colorScheme: colorScheme ?? contextConfig.colorScheme,
|
||||
colorPalette: colorPalette ?? contextConfig.colorPalette,
|
||||
scaleFactor: scaleFactor ?? contextConfig.scaleFactor,
|
||||
|
|
@ -2308,6 +2311,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
|||
dimensions={standardDims}
|
||||
scaleFactor={finalConfig.scaleFactor}
|
||||
beadShape={finalConfig.beadShape}
|
||||
customBeadContent={finalConfig.customBeadContent}
|
||||
colorScheme={finalConfig.colorScheme}
|
||||
colorPalette={finalConfig.colorPalette}
|
||||
hideInactiveBeads={finalConfig.hideInactiveBeads}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,10 @@ export interface BeadComponentProps {
|
|||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
shape: "circle" | "diamond" | "square";
|
||||
shape: "circle" | "diamond" | "square" | "custom";
|
||||
color: string;
|
||||
hideInactiveBeads: boolean;
|
||||
customBeadContent?: import("./AbacusContext").CustomBeadContent;
|
||||
customStyle?: {
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
|
|
@ -84,7 +85,8 @@ export interface AbacusSVGRendererProps {
|
|||
scaleFactor?: number;
|
||||
|
||||
// Appearance
|
||||
beadShape: "circle" | "diamond" | "square";
|
||||
beadShape: "circle" | "diamond" | "square" | "custom";
|
||||
customBeadContent?: import("./AbacusContext").CustomBeadContent;
|
||||
colorScheme: string;
|
||||
colorPalette: string;
|
||||
hideInactiveBeads: boolean;
|
||||
|
|
@ -141,6 +143,7 @@ export function AbacusSVGRenderer({
|
|||
dimensions,
|
||||
scaleFactor = 1,
|
||||
beadShape,
|
||||
customBeadContent,
|
||||
colorScheme,
|
||||
colorPalette,
|
||||
hideInactiveBeads,
|
||||
|
|
@ -405,6 +408,7 @@ export function AbacusSVGRenderer({
|
|||
y: position.y,
|
||||
size: beadSize,
|
||||
shape: beadShape,
|
||||
customBeadContent,
|
||||
color,
|
||||
hideInactiveBeads,
|
||||
customStyle,
|
||||
|
|
|
|||
|
|
@ -4,15 +4,17 @@
|
|||
*/
|
||||
|
||||
import type { BeadConfig, BeadStyle } from "./AbacusReact";
|
||||
import type { CustomBeadContent, CustomBeadContext } from "./AbacusContext";
|
||||
|
||||
export interface StaticBeadProps {
|
||||
bead: BeadConfig;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
shape: "diamond" | "square" | "circle";
|
||||
shape: "diamond" | "square" | "circle" | "custom";
|
||||
color: string;
|
||||
customStyle?: BeadStyle;
|
||||
customBeadContent?: CustomBeadContent;
|
||||
hideInactiveBeads?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +26,7 @@ export function AbacusStaticBead({
|
|||
shape,
|
||||
color,
|
||||
customStyle,
|
||||
customBeadContent,
|
||||
hideInactiveBeads = false,
|
||||
}: StaticBeadProps) {
|
||||
// Don't render inactive beads if hideInactiveBeads is true
|
||||
|
|
@ -39,6 +42,7 @@ export function AbacusStaticBead({
|
|||
|
||||
// Calculate offset based on shape (matching AbacusReact positioning)
|
||||
const getXOffset = () => {
|
||||
if (shape === "custom") return halfSize;
|
||||
return shape === "diamond" ? size * 0.7 : halfSize;
|
||||
};
|
||||
|
||||
|
|
@ -49,6 +53,96 @@ export function AbacusStaticBead({
|
|||
const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`;
|
||||
|
||||
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) {
|
||||
case "diamond":
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export type {
|
|||
ColorScheme,
|
||||
BeadShape,
|
||||
ColorPalette,
|
||||
CustomBeadContent,
|
||||
CustomBeadContext,
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType,
|
||||
} from "./AbacusContext";
|
||||
|
|
|
|||
Loading…
Reference in New Issue