soroban-abacus-flashcards/templates/flashcards.typ

554 lines
17 KiB
Plaintext

#let draw-soroban(value, columns: auto, show-empty: false, hide-inactive: false, bead-shape: "diamond", color-scheme: "monochrome", base-size: 1.0) = {
// Parse the value into digits
let digits = if type(value) == int {
str(value).clusters().map(d => int(d))
} else {
panic("Value must be an integer")
}
// Determine number of columns
let num-columns = if columns == auto {
digits.len()
} else {
columns
}
// Pad with leading zeros if needed
let padded-digits = if digits.len() < num-columns {
(0,) * (num-columns - digits.len()) + digits
} else {
digits.slice(calc.max(0, digits.len() - num-columns))
}
// Skip leading zeros if show-empty is false
let start-idx = if not show-empty {
let first-nonzero = padded-digits.position(d => d != 0)
if first-nonzero == none { 0 } else { first-nonzero }
} else { 0 }
let display-digits = padded-digits.slice(start-idx)
if display-digits.len() == 0 { display-digits = (0,) }
// Drawing parameters scaled by base-size
let rod-width = 3pt * base-size
let bead-size = 12pt * base-size
let bead-spacing = 4pt * base-size
let column-spacing = 25pt * base-size
let heaven-earth-gap = 30pt * base-size
let bar-thickness = 2pt * base-size
// Color schemes
let place-value-colors = (
rgb("#2E86AB"), // ones - blue
rgb("#A23B72"), // tens - magenta
rgb("#F18F01"), // hundreds - orange
rgb("#6A994E"), // thousands - green
rgb("#BC4B51"), // ten-thousands - red
)
let get-column-color(col-idx, total-cols, scheme) = {
if scheme == "place-value" {
// Right-to-left: rightmost is ones
let place-idx = total-cols - col-idx - 1
let color-idx = calc.rem(place-idx, place-value-colors.len())
place-value-colors.at(color-idx)
} else if scheme == "alternating" {
if calc.rem(col-idx, 2) == 0 { rgb("#1E88E5") } else { rgb("#43A047") }
} else if scheme == "heaven-earth" {
black // Will be overridden per bead type
} else {
black // monochrome
}
}
let inactive-color = gray.lighten(70%)
let active-color = black
// Function to draw a bead based on shape
// y parameter represents the CENTER of where the bead should be
let draw-bead(x, y, shape, fill-color) = {
if shape == "diamond" {
// Horizontally elongated diamond (rhombus)
place(
dx: x - bead-size * 0.7,
dy: y - bead-size / 2,
polygon(
(bead-size * 0.7, 0pt), // left point
(bead-size * 1.4, bead-size / 2), // top point
(bead-size * 0.7, bead-size), // right point
(0pt, bead-size / 2), // bottom point
fill: fill-color,
stroke: 0.5pt + black
)
)
} else if shape == "square" {
// Square bead
place(
dx: x - bead-size / 2,
dy: y - bead-size / 2,
rect(
width: bead-size,
height: bead-size,
fill: fill-color,
stroke: 0.5pt + black,
radius: 1pt // Slight rounding
)
)
} else {
// Circle (traditional option)
// Use a box to position the circle from top-left like other shapes
place(
dx: x - bead-size / 2,
dy: y - bead-size / 2,
box(
width: bead-size,
height: bead-size,
align(center + horizon, circle(
radius: bead-size / 2,
fill: fill-color,
stroke: 0.5pt + black
))
)
)
}
}
// Calculate total width and height
let total-width = display-digits.len() * column-spacing
let total-height = heaven-earth-gap + 5 * (bead-size + bead-spacing) + 10pt
box(width: total-width, height: total-height)[
#place(top + left)[
#for (idx, digit) in display-digits.enumerate() [
#let x-offset = idx * column-spacing + column-spacing / 2
// Decompose digit into heaven (5s) and earth (1s) beads
#let heaven-active = if digit >= 5 { 1 } else { 0 }
#let earth-active = calc.rem(digit, 5)
// Draw rod
#place(
dx: x-offset - rod-width / 2,
dy: 0pt,
rect(
width: rod-width,
height: total-height,
fill: gray.lighten(80%),
stroke: none
)
)
// Draw heaven bead
// Position inactive earth bead gap from reckoning bar: 19px you measured
// Convert to same gap for heaven: heaven-earth-gap - gap - bead-size/2
#let earth-gap = 19pt // Exact same gap as earth beads
#let heaven-y = if heaven-active == 1 {
heaven-earth-gap - bead-size / 2 - 1pt // Active (center just above bar)
} else {
heaven-earth-gap - earth-gap - bead-size / 2 // Inactive (same gap as earth, measured from reckoning bar)
}
#let bead-color = if heaven-active == 1 {
if color-scheme == "heaven-earth" {
rgb("#F18F01") // Orange for heaven beads
} else {
get-column-color(idx, display-digits.len(), color-scheme)
}
} else {
inactive-color
}
#if heaven-active == 1 or not hide-inactive [
#draw-bead(
x-offset,
heaven-y,
bead-shape,
bead-color
)
]
// Draw earth beads
#for i in range(4) [
#let is-active = i < earth-active
#let earth-y = if is-active {
heaven-earth-gap + bar-thickness + 1pt + bead-size / 2 + i * (bead-size + bead-spacing)
} else {
total-height - (4 - i) * (bead-size + bead-spacing) - 5pt + bead-size / 2
}
#let earth-bead-color = if is-active {
if color-scheme == "heaven-earth" {
rgb("#2E86AB") // Blue for earth beads
} else {
get-column-color(idx, display-digits.len(), color-scheme)
}
} else {
inactive-color
}
#if is-active or not hide-inactive [
#draw-bead(
x-offset,
earth-y,
bead-shape,
earth-bead-color
)
]
]
]
// Draw reckoning bar
#place(
dx: 0pt,
dy: heaven-earth-gap,
rect(
width: total-width,
height: bar-thickness,
fill: black,
stroke: none
)
)
]
]
}
#let scale-to-fit(content, max-width, max-height, manual-scale: 1.0) = {
context {
// Measure the content
let measured = measure(content)
// Calculate scale factors
let scale-x = max-width / measured.width
let scale-y = max-height / measured.height
// Use the smaller scale to maintain aspect ratio
let auto-scale = calc.min(scale-x, scale-y)
// Apply manual scale adjustment
let final-scale = auto-scale * manual-scale
// Return scaled content
scale(x: final-scale * 100%, y: final-scale * 100%)[#content]
}
}
#let flashcard(
front-content,
back-content,
card-width: 3.5in,
card-height: 2.5in,
safe-margin: 5mm,
show-cut-marks: false,
show-registration: false,
scale-factor: 0.9
) = {
let usable-width = card-width - 2 * safe-margin
let usable-height = card-height - 2 * safe-margin
let card = rect(
width: card-width,
height: card-height,
stroke: if show-cut-marks { 0.25pt + gray } else { none },
radius: 0pt
)[
#align(center + horizon)[
#scale-to-fit(front-content, usable-width, usable-height, manual-scale: scale-factor)
]
// Registration mark
#if show-registration {
place(
bottom + right,
dx: -2mm,
dy: -2mm,
circle(radius: 0.5mm, fill: gray.lighten(70%))
)
}
]
(
front: card,
back: rect(
width: card-width,
height: card-height,
stroke: if show-cut-marks { 0.25pt + gray } else { none },
radius: 0pt
)[
#align(center + horizon)[
#scale-to-fit(back-content, usable-width, usable-height, manual-scale: scale-factor)
]
// Registration mark
#if show-registration {
place(
bottom + left,
dx: 2mm,
dy: -2mm,
circle(radius: 0.5mm, fill: gray.lighten(70%))
)
}
]
)
}
#let generate-flashcards(
numbers,
cards-per-page: 6,
paper-size: "us-letter",
orientation: "portrait",
margins: (top: 0.5in, bottom: 0.5in, left: 0.5in, right: 0.5in),
gutter: 5mm,
show-cut-marks: false,
show-registration: false,
font-family: "DejaVu Sans",
font-size: 48pt,
columns: auto,
show-empty-columns: false,
hide-inactive-beads: false,
bead-shape: "diamond",
color-scheme: "monochrome",
colored-numerals: false,
scale-factor: 0.9 // Manual scale adjustment (0.1 to 1.0)
) = {
// Set document properties
set document(
title: "Soroban Flashcards",
author: "Soroban Flashcard Generator",
keywords: ("soroban", "abacus", "flashcards", "education", "math"),
date: auto
)
set page(
paper: paper-size,
margin: margins,
flipped: orientation == "landscape"
)
set text(font: font-family, size: font-size, fallback: true)
// Calculate card dimensions
let page-width = if orientation == "portrait" { 8.5in } else { 11in }
let page-height = if orientation == "portrait" { 11in } else { 8.5in }
let usable-width = page-width - margins.left - margins.right
let usable-height = page-height - margins.top - margins.bottom
// Determine grid layout
let (cols, rows) = if cards-per-page == 1 {
(1, 1)
} else if cards-per-page == 2 {
(1, 2)
} else if cards-per-page == 3 {
(1, 3)
} else if cards-per-page == 4 {
(2, 2)
} else if cards-per-page == 6 {
(2, 3)
} else if cards-per-page == 8 {
(2, 4)
} else if cards-per-page == 9 {
(3, 3)
} else if cards-per-page == 10 {
(2, 5)
} else if cards-per-page == 12 {
(3, 4)
} else if cards-per-page == 15 {
(3, 5)
} else if cards-per-page == 16 {
(4, 4)
} else if cards-per-page == 18 {
(3, 6)
} else if cards-per-page == 20 {
(4, 5)
} else if cards-per-page == 24 {
(4, 6)
} else if cards-per-page == 25 {
(5, 5)
} else if cards-per-page == 30 {
(5, 6)
} else {
// Try to find a reasonable grid for other values
let sqrt-cards = calc.sqrt(cards-per-page)
let cols-guess = calc.ceil(sqrt-cards)
let rows-guess = calc.ceil(cards-per-page / cols-guess)
(cols-guess, rows-guess)
}
let card-width = (usable-width - gutter * (cols - 1)) / cols
let card-height = (usable-height - gutter * (rows - 1)) / rows
// Adaptive sizing based on card dimensions
// Calculate a base scale factor based on card size compared to default
let default-card-width = 3.5in
let default-card-height = 2.5in
let width-scale = card-width / default-card-width
let height-scale = card-height / default-card-height
let base-scale = calc.min(width-scale, height-scale)
// Adaptive font size based on card dimensions
let base-font-size = if font-size == 48pt {
// Auto-scale default font size based on card height
48pt * base-scale
} else {
font-size
}
// Function to create colored numeral based on color scheme
let create-colored-numeral(num, scheme, use-colors, font-size) = {
// Use the exact same colors as the beads
let place-value-colors = (
rgb("#2E86AB"), // ones - blue (same as beads)
rgb("#A23B72"), // tens - magenta (same as beads)
rgb("#F18F01"), // hundreds - orange (same as beads)
rgb("#6A994E"), // thousands - green (same as beads)
rgb("#BC4B51"), // ten-thousands - red (same as beads)
)
if not use-colors or scheme == "monochrome" {
// Plain black text
text(size: font-size)[#num]
} else if scheme == "place-value" {
// Color each digit according to its place value
let digits = str(num).clusters()
let num-digits = digits.len()
let colored-digits = ()
for (idx, digit) in digits.enumerate() {
let place-idx = num-digits - idx - 1 // 0 = ones, 1 = tens, etc.
let color-idx = calc.rem(place-idx, place-value-colors.len())
let digit-color = place-value-colors.at(color-idx)
colored-digits += (text(fill: digit-color, size: font-size)[#digit],)
}
colored-digits.join()
} else if scheme == "heaven-earth" {
// For heaven-earth, use orange (heaven bead color)
text(size: font-size, fill: rgb("#F18F01"))[#num]
} else if scheme == "alternating" {
// For alternating, we could alternate digit colors
let digits = str(num).clusters()
let colored-digits = ()
for (idx, digit) in digits.enumerate() {
let digit-color = if calc.rem(idx, 2) == 0 { rgb("#1E88E5") } else { rgb("#43A047") }
colored-digits += (text(fill: digit-color, size: font-size)[#digit],)
}
colored-digits.join()
} else {
text(size: font-size)[#num]
}
}
// Generate cards
let cards = numbers.map(num => {
flashcard(
draw-soroban(num, columns: columns, show-empty: show-empty-columns, hide-inactive: hide-inactive-beads, bead-shape: bead-shape, color-scheme: color-scheme, base-size: base-scale),
create-colored-numeral(num, color-scheme, colored-numerals, base-font-size),
card-width: card-width,
card-height: card-height,
show-cut-marks: show-cut-marks,
show-registration: show-registration,
scale-factor: scale-factor
)
})
// Function to draw cutting guides
let draw-cutting-guides(cols, rows, card-width, card-height, gutter, usable-width, usable-height) = {
// Use subtle gray lines that won't be too distracting if cut is slightly off
let guide-color = gray.lighten(50%)
let guide-stroke = 0.25pt + guide-color
// Draw horizontal cutting guides
for row in range(rows - 1) {
let y-pos = (row + 1) * card-height + row * gutter + gutter / 2
place(
dx: -margins.left, // Extend to page edge
dy: y-pos,
line(
start: (0pt, 0pt),
end: (usable-width + margins.left + margins.right, 0pt),
stroke: guide-stroke
)
)
}
// Draw vertical cutting guides
for col in range(cols - 1) {
let x-pos = (col + 1) * card-width + col * gutter + gutter / 2
place(
dx: x-pos,
dy: -margins.top, // Extend to page edge
line(
start: (0pt, 0pt),
end: (0pt, usable-height + margins.top + margins.bottom),
stroke: guide-stroke
)
)
}
}
// Layout pages - alternating front and back for duplex printing
let total-cards = cards.len()
let total-pages = calc.ceil(total-cards / cards-per-page)
// Generate all pages in front/back pairs for proper duplex printing
for page-idx in range(total-pages) {
let start-idx = page-idx * cards-per-page
let end-idx = calc.min(start-idx + cards-per-page, total-cards)
let page-cards = cards.slice(start-idx, end-idx)
// FRONT SIDE (odd page numbers: 1, 3, 5...)
// This will be the soroban bead side
place(
grid(
columns: (card-width,) * cols,
rows: (card-height,) * rows,
column-gutter: gutter,
row-gutter: gutter,
..page-cards.map(c => c.front)
)
)
// Draw cutting guides on top if enabled
if show-cut-marks {
draw-cutting-guides(cols, rows, card-width, card-height, gutter, usable-width, usable-height)
}
// Always add page break after front side
pagebreak()
// BACK SIDE (even page numbers: 2, 4, 6...)
// This will be the numeral side
// Mirrored horizontally for long-edge duplex binding
place(
grid(
columns: (card-width,) * cols,
rows: (card-height,) * rows,
column-gutter: gutter,
row-gutter: gutter,
..range(rows).map(r => {
// Reverse columns for proper back-side alignment
range(cols).rev().map(c => {
let idx = r * cols + c
if idx < page-cards.len() {
page-cards.at(idx).back
} else {
// Empty space for incomplete grids
rect(width: card-width, height: card-height, stroke: none)[]
}
})
}).flatten()
)
)
// Draw cutting guides on back side too
if show-cut-marks {
draw-cutting-guides(cols, rows, card-width, card-height, gutter, usable-width, usable-height)
}
// Add page break except after the last page
if page-idx < total-pages - 1 {
pagebreak()
}
}
}