All files / web/src/utils/calendar generateCalendarComposite.tsx

0% Statements 0/228
0% Branches 0/1
0% Functions 0/1
0% Lines 0/228

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229                                                                                                                                                                                                                                                                                                                                                                                                                                                                         
/**
 * Generate a complete monthly calendar as a single SVG
 * This prevents multi-page overflow - one image scales to fit
 */

import type React from 'react'
import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static'

interface CalendarCompositeOptions {
  month: number
  year: number
  renderToString: (element: React.ReactElement) => string
}

const MONTH_NAMES = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
]

const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

function getDaysInMonth(year: number, month: number): number {
  return new Date(year, month, 0).getDate()
}

function getFirstDayOfWeek(year: number, month: number): number {
  return new Date(year, month - 1, 1).getDay()
}

export function generateCalendarComposite(options: CalendarCompositeOptions): string {
  const { month, year, renderToString } = options
  const daysInMonth = getDaysInMonth(year, month)
  const firstDayOfWeek = getFirstDayOfWeek(year, month)
  const monthName = MONTH_NAMES[month - 1]

  // Layout constants for US Letter aspect ratio (8.5 x 11)
  const WIDTH = 850
  const HEIGHT = 1100
  const MARGIN = 50
  const CONTENT_WIDTH = WIDTH - MARGIN * 2
  const CONTENT_HEIGHT = HEIGHT - MARGIN * 2

  // Abacus natural size is 120x230 at scale=1
  const ABACUS_NATURAL_WIDTH = 120
  const ABACUS_NATURAL_HEIGHT = 230

  // Calculate how many columns needed for year
  const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))

  // Year abacus dimensions (calculate first to determine header height)
  // Use the shared dimension calculator so we stay in sync with AbacusStatic
  const { width: yearAbacusActualWidth, height: yearAbacusActualHeight } =
    calculateAbacusDimensions({
      columns: yearColumns,
      showNumbers: false,
      columnLabels: [],
    })

  const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page
  const yearAbacusDisplayHeight =
    (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth

  // Header - sized to fit month name + year abacus
  const MONTH_NAME_HEIGHT = 40
  const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing
  const TITLE_Y = MARGIN + 35
  const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
  const yearAbacusY = TITLE_Y + 10

  // Calendar grid
  const GRID_START_Y = MARGIN + HEADER_HEIGHT
  const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT
  const WEEKDAY_ROW_HEIGHT = 25
  const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT

  // 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case)
  const CELL_WIDTH = CONTENT_WIDTH / 7
  const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6

  // Day abacus sizing - fit in cell with padding
  const CELL_PADDING = 5

  // Calculate max scale to fit in cell
  const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH
  const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT
  const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room

  const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE
  const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE

  // Generate calendar grid
  const calendarCells: (number | null)[] = []
  for (let i = 0; i < firstDayOfWeek; i++) {
    calendarCells.push(null)
  }
  for (let day = 1; day <= daysInMonth; day++) {
    calendarCells.push(day)
  }

  // Render individual abacus SVGs as complete SVG elements
  function renderAbacusSVG(value: number, columns: number, scale: number): string {
    return renderToString(
      <AbacusStatic
        value={value}
        columns={columns}
        scaleFactor={scale}
        showNumbers={false}
        frameVisible={true}
        compact={false}
        hideInactiveBeads={true}
        cropToActiveBeads={{
          padding: { top: 8, bottom: 2, left: 5, right: 5 },
        }}
      />
    )
  }

  // Main composite SVG
  const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
  <!-- Background -->
  <rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>

  <!-- Title: Month Name -->
  <text x="${WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-family="Arial" font-size="32" font-weight="bold" fill="#1a1a1a">
    ${monthName}
  </text>

  <!-- Year Abacus (centered below month name) -->
  ${(() => {
    const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1)
    const yearAbacusContent = yearAbacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
    return `<svg x="${yearAbacusX}" y="${yearAbacusY}" width="${yearAbacusDisplayWidth}" height="${yearAbacusDisplayHeight}"
         viewBox="0 0 ${yearAbacusActualWidth} ${yearAbacusActualHeight}">
    ${yearAbacusContent}
  </svg>`
  })()}

  <!-- Weekday Headers -->
  ${WEEKDAYS.map(
    (day, i) => `
  <text x="${MARGIN + i * CELL_WIDTH + CELL_WIDTH / 2}" y="${GRID_START_Y + 18}"
        text-anchor="middle" font-family="Arial" font-size="14" font-weight="bold" fill="#555">
    ${day}
  </text>`
  ).join('')}

  <!-- Separator line under weekdays -->
  <line x1="${MARGIN}" y1="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
        x2="${WIDTH - MARGIN}" y2="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
        stroke="#333" stroke-width="2"/>

  <!-- Calendar Grid Cells -->
  ${calendarCells
    .map((day, index) => {
      const row = Math.floor(index / 7)
      const col = index % 7
      const cellX = MARGIN + col * CELL_WIDTH
      const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT

      return `
  <rect x="${cellX}" y="${cellY}" width="${CELL_WIDTH}" height="${DAY_CELL_HEIGHT}"
        fill="none" stroke="#333" stroke-width="2"/>`
    })
    .join('')}

  <!-- Calendar Day Abaci -->
  ${calendarCells
    .map((day, index) => {
      if (day === null) return ''

      const row = Math.floor(index / 7)
      const col = index % 7
      const cellX = MARGIN + col * CELL_WIDTH
      const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT

      // Render cropped abacus SVG
      const abacusSVG = renderAbacusSVG(day, 2, 1)

      // Extract viewBox and dimensions from the cropped SVG
      const viewBoxMatch = abacusSVG.match(/viewBox="([^"]*)"/)
      const widthMatch = abacusSVG.match(/width="?([0-9.]+)"?/)
      const heightMatch = abacusSVG.match(/height="?([0-9.]+)"?/)

      const croppedViewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 120 230'
      const croppedWidth = widthMatch ? parseFloat(widthMatch[1]) : ABACUS_NATURAL_WIDTH
      const croppedHeight = heightMatch ? parseFloat(heightMatch[1]) : ABACUS_NATURAL_HEIGHT

      // Calculate scale to fit cropped abacus in cell
      const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / croppedWidth
      const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / croppedHeight
      const fitScale = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.95 // 95% to leave breathing room

      const scaledWidth = croppedWidth * fitScale
      const scaledHeight = croppedHeight * fitScale

      // Center abacus in cell
      const abacusCenterX = cellX + CELL_WIDTH / 2
      const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2

      // Offset to top-left corner of abacus
      const abacusX = abacusCenterX - scaledWidth / 2
      const abacusY = abacusCenterY - scaledHeight / 2

      // Extract SVG content (remove outer <svg> tags)
      const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')

      return `
  <!-- Day ${day} (row ${row}, col ${col}) -->
  <svg x="${abacusX}" y="${abacusY}" width="${scaledWidth}" height="${scaledHeight}"
       viewBox="${croppedViewBox}">
    ${svgContent}
  </svg>`
    })
    .join('')}
</svg>`

  return compositeSVG
}