| "use strict"; |
|
|
| const entryView = document.querySelector("#entry-view"); |
| const experienceView = document.querySelector("#experience-view"); |
| const form = document.querySelector("#forest-form"); |
| const formSteps = Array.from(document.querySelectorAll(".form-step")); |
| const stepTracker = document.querySelector("#step-tracker"); |
| const nameInput = document.querySelector("#name"); |
| const situationInput = document.querySelector("#situation"); |
| const styleInput = document.querySelector("#style"); |
| const growButton = document.querySelector("#grow-button"); |
| const statusRegion = document.querySelector("#status-region"); |
| const intakeStep = document.querySelector("#intake-step"); |
| const intakeQuestion = document.querySelector("#intake-question"); |
| const intakeOptions = document.querySelector("#intake-options"); |
| const intakeProgress = document.querySelector("#intake-progress"); |
| const styleStep = document.querySelector("#style-step"); |
| const forestTitle = document.querySelector("#forest-title"); |
| const clearingProgress = document.querySelector("#clearing-progress"); |
| const artStyleLabel = document.querySelector("#art-style-label"); |
| const progressDots = document.querySelector("#progress-dots"); |
| const soundscapePlayer = document.querySelector("#soundscape-player"); |
| const soundscapeAudio = document.querySelector("#soundscape-audio"); |
| const clearingImage = document.querySelector("#clearing-image"); |
| const sceneTitle = document.querySelector("#scene-title"); |
| const sceneIntro = document.querySelector("#scene-intro"); |
| const narration = document.querySelector("#narration"); |
| const reflection = document.querySelector("#reflection"); |
| const spell = document.querySelector("#spell"); |
| const copySpell = document.querySelector("#copy-spell"); |
| const walkOn = document.querySelector("#walk-on"); |
| const saveForest = document.querySelector("#save-forest"); |
| const startOver = document.querySelector("#start-over"); |
| const brandHome = document.querySelector("#brand-home"); |
|
|
| const INTAKE_TURNS = 5; |
|
|
| const STYLE_LABELS = { |
| watercolor: "Watercolor Storybook", |
| paper_cut: "Layered Paper Cut", |
| moonlit_gouache: "Moonlit Gouache", |
| botanical_ink: "Botanical Ink Wash", |
| }; |
|
|
| const state = { |
| forestTitle: "", |
| clearingCount: 0, |
| clearings: [], |
| currentIndex: 0, |
| currentStep: "name", |
| intake: [], |
| pendingQuestion: null, |
| complete: false, |
| name: "", |
| situation: "", |
| style: "surprise", |
| resolvedStyle: "", |
| seed: 0, |
| }; |
|
|
| function randomSeed() { |
| const values = new Uint32Array(1); |
| crypto.getRandomValues(values); |
| return values[0] % 2147483648; |
| } |
|
|
| function setStatus(message, isError = false) { |
| statusRegion.textContent = message; |
| statusRegion.classList.toggle("is-error", isError); |
| } |
|
|
| function updateStepTracker() { |
| const items = Array.from(stepTracker.querySelectorAll("li")); |
| let currentSlot; |
| if (state.currentStep === "name") currentSlot = 0; |
| else if (state.currentStep === "style") currentSlot = 6; |
| else if (state.currentStep === "intake") currentSlot = 1 + state.intake.length; |
| else currentSlot = 0; |
| for (const [index, item] of items.entries()) { |
| item.classList.toggle("is-current", index === currentSlot); |
| item.classList.toggle("is-complete", index < currentSlot); |
| } |
| } |
|
|
| function showStep(step, focus = true) { |
| state.currentStep = step; |
| const lookup = { |
| name: '[data-step="0"]', |
| intake: '[data-step="intake"]', |
| style: '[data-step="style"]', |
| }; |
| for (const section of formSteps) { |
| section.hidden = true; |
| section.classList.remove("is-active"); |
| } |
| const active = document.querySelector(lookup[step]); |
| if (active) { |
| active.hidden = false; |
| active.classList.add("is-active"); |
| if (focus) { |
| const target = active.querySelector("input, textarea, button, select"); |
| if (target) target.focus({ preventScroll: true }); |
| } |
| } |
| updateStepTracker(); |
| } |
|
|
| function resetState() { |
| state.forestTitle = ""; |
| state.clearingCount = 0; |
| state.clearings = []; |
| state.currentIndex = 0; |
| state.intake = []; |
| state.pendingQuestion = null; |
| state.complete = false; |
| state.resolvedStyle = ""; |
| statusRegion.textContent = ""; |
| statusRegion.classList.remove("is-error"); |
| artStyleLabel.textContent = ""; |
| soundscapeAudio.pause(); |
| soundscapeAudio.removeAttribute("src"); |
| soundscapeAudio.load(); |
| soundscapePlayer.hidden = true; |
| saveForest.hidden = true; |
| walkOn.hidden = false; |
| walkOn.disabled = false; |
| walkOn.querySelector("span").textContent = "Walk on"; |
| } |
|
|
| function renderIntakeQuestion(question) { |
| state.pendingQuestion = question; |
| intakeProgress.textContent = `Question ${state.intake.length + 1} of ${INTAKE_TURNS}`; |
| intakeQuestion.textContent = question.question; |
| intakeOptions.replaceChildren(); |
| for (const option of question.options) { |
| const label = document.createElement("label"); |
| label.className = "choice-card"; |
| const sanitized = option.replace(/"/g, """); |
| label.innerHTML = ` |
| <input type="radio" name="intake-option" value="${sanitized}" /> |
| <span>${option}</span> |
| `; |
| intakeOptions.append(label); |
| } |
| } |
|
|
| async function fetchNextIntakeQuestion() { |
| setStatus("The forest is listening… (first response can take ~60s while it wakes)"); |
| let response; |
| try { |
| response = await fetch("/api/intake/next", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| name: state.name, |
| situation: state.situation, |
| history: state.intake, |
| }), |
| }); |
| } catch (networkError) { |
| throw new Error( |
| `Could not reach the forest (network): ${networkError.message}`, |
| ); |
| } |
| if (!response.ok) { |
| let detail = ""; |
| try { |
| const body = await response.json(); |
| detail = body && body.detail ? body.detail : JSON.stringify(body); |
| } catch { |
| detail = await response.text().catch(() => ""); |
| } |
| throw new Error( |
| `The forest is too quiet (HTTP ${response.status}): ${detail.slice(0, 800)}`, |
| ); |
| } |
| const question = await response.json(); |
| setStatus("The forest has a question for you."); |
| return question; |
| } |
|
|
| async function startIntake() { |
| state.name = nameInput.value.trim(); |
| state.situation = situationInput.value.trim(); |
| state.intake = []; |
| showStep("intake"); |
| await advanceIntake(); |
| } |
|
|
| async function advanceIntake() { |
| if (state.intake.length >= INTAKE_TURNS) { |
| showStep("style"); |
| setStatus(""); |
| return; |
| } |
| try { |
| const question = await fetchNextIntakeQuestion(); |
| renderIntakeQuestion(question); |
| } catch (error) { |
| setStatus(error.message, true); |
| } |
| } |
|
|
| function validateNameStep() { |
| for (const input of [nameInput, situationInput]) { |
| if (!input.checkValidity()) { |
| input.reportValidity(); |
| return false; |
| } |
| } |
| return true; |
| } |
|
|
| function validateIntakeChoice() { |
| const checked = intakeOptions.querySelector('input[name="intake-option"]:checked'); |
| if (!checked) { |
| setStatus("Choose one option before walking on.", true); |
| return null; |
| } |
| setStatus(""); |
| return checked.value; |
| } |
|
|
| for (const button of document.querySelectorAll(".step-next")) { |
| button.addEventListener("click", async () => { |
| if (state.currentStep === "name") { |
| if (!validateNameStep()) return; |
| await startIntake(); |
| return; |
| } |
| if (state.currentStep === "intake") { |
| const answer = validateIntakeChoice(); |
| if (!answer || !state.pendingQuestion) return; |
| state.intake.push({ question: state.pendingQuestion.question, answer }); |
| state.pendingQuestion = null; |
| updateStepTracker(); |
| await advanceIntake(); |
| return; |
| } |
| }); |
| } |
|
|
| for (const button of document.querySelectorAll(".step-back")) { |
| button.addEventListener("click", async () => { |
| if (state.currentStep === "intake") { |
| if (state.intake.length === 0) { |
| showStep("name"); |
| return; |
| } |
| state.intake.pop(); |
| state.pendingQuestion = null; |
| updateStepTracker(); |
| await advanceIntake(); |
| return; |
| } |
| if (state.currentStep === "style") { |
| |
| if (state.intake.length > 0) state.intake.pop(); |
| state.pendingQuestion = null; |
| showStep("intake"); |
| updateStepTracker(); |
| await advanceIntake(); |
| } |
| }); |
| } |
|
|
| form.addEventListener("submit", async (submitEvent) => { |
| submitEvent.preventDefault(); |
| if (state.currentStep !== "style") return; |
| state.style = styleInput.value; |
| state.seed = randomSeed(); |
| growButton.disabled = true; |
| growButton.querySelector("span").textContent = "Growing your path"; |
| setStatus("The forest is shaping the walk you talked about."); |
|
|
| try { |
| const response = await fetch("/api/forest", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| name: state.name, |
| situation: state.situation, |
| style: state.style, |
| seed: state.seed, |
| intake: state.intake, |
| }), |
| }); |
| await readForestStream(response); |
| } catch (error) { |
| setStatus( |
| error.message || "The forest could not begin. Please try again.", |
| true, |
| ); |
| growButton.disabled = false; |
| growButton.querySelector("span").textContent = "Grow my forest"; |
| } |
| }); |
|
|
| function buildDots() { |
| progressDots.replaceChildren(); |
| const count = state.clearingCount || state.clearings.length; |
| for (let index = 0; index < count; index += 1) { |
| const dot = document.createElement("i"); |
| dot.className = "progress-dot"; |
| if (index < state.currentIndex) dot.classList.add("is-complete"); |
| if (index === state.currentIndex) dot.classList.add("is-current"); |
| progressDots.append(dot); |
| } |
| } |
|
|
| function updateActions() { |
| const hasNext = state.currentIndex + 1 < state.clearings.length; |
| const atKnownEnd = state.currentIndex === state.clearings.length - 1; |
| const atFinalEnd = state.complete && atKnownEnd; |
|
|
| walkOn.hidden = atFinalEnd; |
| saveForest.hidden = !atFinalEnd; |
| walkOn.disabled = !hasNext; |
| walkOn.querySelector("span").textContent = hasNext |
| ? "Walk on" |
| : "Painting the next clearing"; |
| } |
|
|
| function renderNarration(text) { |
| narration.replaceChildren(); |
| const paragraphs = text.split(/\n{2,}/).map((piece) => piece.trim()).filter(Boolean); |
| for (const paragraph of paragraphs) { |
| const node = document.createElement("p"); |
| node.textContent = paragraph; |
| narration.append(node); |
| } |
| } |
|
|
| function renderCurrent() { |
| const item = state.clearings[state.currentIndex]; |
| if (!item) return; |
|
|
| entryView.hidden = true; |
| experienceView.hidden = false; |
| forestTitle.textContent = state.forestTitle; |
| clearingProgress.textContent = `Clearing ${state.currentIndex + 1} of ${ |
| state.clearingCount || state.clearings.length |
| }`; |
| if (item.image) { |
| clearingImage.src = item.image; |
| clearingImage.alt = item.clearing.scene_title; |
| clearingImage.hidden = false; |
| } else { |
| clearingImage.removeAttribute("src"); |
| clearingImage.alt = ""; |
| clearingImage.hidden = true; |
| } |
| sceneTitle.textContent = item.clearing.scene_title; |
| sceneIntro.textContent = item.clearing.scene_intro; |
| renderNarration(item.clearing.narration); |
| reflection.textContent = item.clearing.reflection; |
| spell.textContent = item.clearing.spell; |
| buildDots(); |
| updateActions(); |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| } |
|
|
| function handleEvent(event) { |
| if (event.type === "status") { |
| setStatus(event.message); |
| return; |
| } |
| if (event.type === "support" || event.type === "error") { |
| setStatus(event.message, true); |
| growButton.disabled = false; |
| growButton.querySelector("span").textContent = "Grow my forest"; |
| return; |
| } |
| if (event.type === "forest") { |
| state.forestTitle = event.data.forest_title; |
| state.clearingCount = event.data.clearing_count || 0; |
| state.resolvedStyle = event.data.style || ""; |
| artStyleLabel.textContent = state.resolvedStyle |
| ? `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}` |
| : ""; |
| setStatus("The first clearing is almost in view."); |
| return; |
| } |
| if (event.type === "clearing") { |
| state.clearings.push(event.data); |
| if (state.clearings.length === 1) { |
| renderCurrent(); |
| } else { |
| updateActions(); |
| } |
| return; |
| } |
| if (event.type === "soundscape") { |
| soundscapeAudio.src = event.data.audio; |
| soundscapePlayer.hidden = false; |
| return; |
| } |
| if (event.type === "complete") { |
| state.complete = true; |
| updateActions(); |
| growButton.disabled = false; |
| growButton.querySelector("span").textContent = "Grow my forest"; |
| } |
| } |
|
|
| async function readForestStream(response) { |
| if (!response.ok || !response.body) { |
| throw new Error("The forest could not begin. Please try again."); |
| } |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ""; |
|
|
| while (true) { |
| const { value, done } = await reader.read(); |
| buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); |
| const lines = buffer.split("\n"); |
| buffer = lines.pop() || ""; |
| for (const line of lines) { |
| if (line.trim()) handleEvent(JSON.parse(line)); |
| } |
| if (done) break; |
| } |
| if (buffer.trim()) handleEvent(JSON.parse(buffer)); |
| } |
|
|
| walkOn.addEventListener("click", () => { |
| if (state.currentIndex + 1 >= state.clearings.length) return; |
| state.currentIndex += 1; |
| renderCurrent(); |
| }); |
|
|
| copySpell.addEventListener("click", async () => { |
| await navigator.clipboard.writeText(spell.textContent); |
| const label = copySpell.querySelector("span"); |
| label.textContent = "Spell copied"; |
| window.setTimeout(() => { |
| label.textContent = "Copy spell"; |
| }, 1600); |
| }); |
|
|
| function splitOversizeWord(context, word, maxWidth) { |
| if (context.measureText(word).width <= maxWidth) return [word]; |
| const pieces = []; |
| let piece = ""; |
| for (const character of word) { |
| const next = piece + character; |
| if (piece && context.measureText(next).width > maxWidth) { |
| pieces.push(piece); |
| piece = character; |
| } else { |
| piece = next; |
| } |
| } |
| if (piece) pieces.push(piece); |
| return pieces; |
| } |
|
|
| function wrapTextLines(context, text, maxWidth) { |
| const words = text |
| .trim() |
| .split(/\s+/) |
| .flatMap((word) => splitOversizeWord(context, word, maxWidth)); |
| const lines = []; |
| let line = ""; |
| for (const word of words) { |
| const next = line ? `${line} ${word}` : word; |
| if (context.measureText(next).width > maxWidth && line) { |
| lines.push(line); |
| line = word; |
| } else { |
| line = next; |
| } |
| } |
| if (line) lines.push(line); |
| return lines.length ? lines : [""]; |
| } |
|
|
| function drawWrappedText(context, lines, x, y, lineHeight) { |
| for (const [index, line] of lines.entries()) { |
| context.fillText(line, x, y + index * lineHeight); |
| } |
| return y + lines.length * lineHeight; |
| } |
|
|
| function measureTextBlock(context, text, font, maxWidth, lineHeight) { |
| context.font = font; |
| const lines = wrapTextLines(context, text, maxWidth); |
| return { font, lines, lineHeight, height: lines.length * lineHeight }; |
| } |
|
|
| function measureForestCardLayout(context) { |
| const textWidth = 800; |
| const title = measureTextBlock( |
| context, |
| state.forestTitle, |
| '58px "Iowan Old Style", Georgia, serif', |
| 1180, |
| 66, |
| ); |
| const headerHeight = |
| 178 + Math.max(0, title.lines.length - 1) * title.lineHeight; |
| let top = headerHeight + 28; |
| const rows = state.clearings.map((item) => { |
| const sceneBlock = measureTextBlock( |
| context, |
| item.clearing.scene_title.toUpperCase(), |
| '600 17px "Avenir Next", Arial, sans-serif', |
| textWidth, |
| 24, |
| ); |
| const introBlock = measureTextBlock( |
| context, |
| item.clearing.scene_intro, |
| 'italic 22px "Iowan Old Style", Georgia, serif', |
| textWidth, |
| 30, |
| ); |
| const narrationBlock = measureTextBlock( |
| context, |
| item.clearing.narration.replace(/\n+/g, " "), |
| '28px "Iowan Old Style", Georgia, serif', |
| textWidth, |
| 38, |
| ); |
| const spellBlock = measureTextBlock( |
| context, |
| item.clearing.spell, |
| '25px "Iowan Old Style", Georgia, serif', |
| textWidth, |
| 34, |
| ); |
| const textHeight = |
| 24 + |
| sceneBlock.height + |
| 8 + |
| introBlock.height + |
| 18 + |
| narrationBlock.height + |
| 36 + |
| spellBlock.height + |
| 18; |
| const height = Math.max(360, textHeight); |
| const row = { |
| item, |
| top, |
| height, |
| sceneBlock, |
| introBlock, |
| narrationBlock, |
| spellBlock, |
| }; |
| top += height + 38; |
| return row; |
| }); |
| return { |
| title, |
| headerHeight, |
| rows, |
| height: top + 92, |
| }; |
| } |
|
|
| function loadImage(source) { |
| return new Promise((resolve, reject) => { |
| const image = new Image(); |
| image.onload = () => resolve(image); |
| image.onerror = reject; |
| image.src = source; |
| }); |
| } |
|
|
| async function drawForestCard() { |
| const width = 1400; |
| const canvas = document.createElement("canvas"); |
| canvas.width = width; |
| let context = canvas.getContext("2d"); |
| const layout = measureForestCardLayout(context); |
| canvas.height = layout.height; |
| context = canvas.getContext("2d"); |
| const height = layout.height; |
|
|
| context.fillStyle = "#f5f0df"; |
| context.fillRect(0, 0, width, height); |
| context.fillStyle = "rgba(83,104,61,0.06)"; |
| for (let index = 0; index < 120; index += 1) { |
| const x = (index * 197) % width; |
| const y = (index * 311) % height; |
| context.beginPath(); |
| context.arc(x, y, 30 + (index % 7) * 9, 0, Math.PI * 2); |
| context.fill(); |
| } |
|
|
| context.fillStyle = "#243c31"; |
| context.textAlign = "center"; |
| context.font = layout.title.font; |
| drawWrappedText( |
| context, |
| layout.title.lines, |
| width / 2, |
| 92, |
| layout.title.lineHeight, |
| ); |
| context.font = '22px "Avenir Next", Arial, sans-serif'; |
| const subtitleY = |
| 138 + Math.max(0, layout.title.lines.length - 1) * layout.title.lineHeight; |
| context.fillText("A path from The Compliment Forest", width / 2, subtitleY); |
| if (state.resolvedStyle) { |
| context.font = '18px "Avenir Next", Arial, sans-serif'; |
| context.fillStyle = "#3f5634"; |
| context.fillText( |
| `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}`, |
| width / 2, |
| subtitleY + 32, |
| ); |
| } |
| context.textAlign = "left"; |
|
|
| for (const row of layout.rows) { |
| const { item, top } = row; |
| try { |
| if (item.image) { |
| const image = await loadImage(item.image); |
| context.save(); |
| context.beginPath(); |
| context.roundRect(80, top, 350, 280, 20); |
| context.clip(); |
| context.drawImage(image, 80, top, 350, 280); |
| context.restore(); |
| } else { |
| throw new Error("No generated image"); |
| } |
| } catch { |
| context.fillStyle = "#d8ddc8"; |
| context.fillRect(80, top, 350, 280); |
| } |
|
|
| context.fillStyle = "#3f5634"; |
| context.font = row.sceneBlock.font; |
| let textY = drawWrappedText( |
| context, |
| row.sceneBlock.lines, |
| 490, |
| top + 24, |
| row.sceneBlock.lineHeight, |
| ); |
| textY += 8; |
| context.fillStyle = "#4c574f"; |
| context.font = row.introBlock.font; |
| textY = drawWrappedText( |
| context, |
| row.introBlock.lines, |
| 490, |
| textY, |
| row.introBlock.lineHeight, |
| ); |
| textY += 18; |
| context.fillStyle = "#243c31"; |
| context.font = row.narrationBlock.font; |
| textY = drawWrappedText( |
| context, |
| row.narrationBlock.lines, |
| 490, |
| textY, |
| row.narrationBlock.lineHeight, |
| ); |
| context.strokeStyle = "rgba(83,104,61,0.45)"; |
| context.lineWidth = 2; |
| context.beginPath(); |
| context.moveTo(490, textY + 18); |
| context.lineTo(1290, textY + 18); |
| context.stroke(); |
| context.font = row.spellBlock.font; |
| context.fillStyle = "#3f5634"; |
| drawWrappedText( |
| context, |
| row.spellBlock.lines, |
| 490, |
| textY + 62, |
| row.spellBlock.lineHeight, |
| ); |
| } |
|
|
| context.textAlign = "center"; |
| context.fillStyle = "#4c574f"; |
| context.font = '18px "Avenir Next", Arial, sans-serif'; |
| context.fillText( |
| "Whimsical encouragement, not a substitute for professional support.", |
| width / 2, |
| height - 60, |
| ); |
| return canvas; |
| } |
|
|
| saveForest.addEventListener("click", async () => { |
| saveForest.disabled = true; |
| saveForest.querySelector("span").textContent = "Pressing leaves into paper"; |
| const canvas = await drawForestCard(); |
| canvas.toBlob((blob) => { |
| const link = document.createElement("a"); |
| link.href = URL.createObjectURL(blob); |
| link.download = "my-compliment-forest.png"; |
| link.click(); |
| URL.revokeObjectURL(link.href); |
| saveForest.disabled = false; |
| saveForest.querySelector("span").textContent = "Save your forest"; |
| }, "image/png"); |
| }); |
|
|
| function showEntry() { |
| resetState(); |
| form.reset(); |
| showStep("name", false); |
| experienceView.hidden = true; |
| entryView.hidden = false; |
| growButton.disabled = false; |
| growButton.querySelector("span").textContent = "Grow my forest"; |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| nameInput.focus(); |
| } |
|
|
| startOver.addEventListener("click", showEntry); |
| brandHome.addEventListener("click", showEntry); |
| showStep("name", false); |
|
|