From c0da8d22862a0afca41ca052c3424564b0011dda Mon Sep 17 00:00:00 2001 From: David Thompson Date: Sat, 22 Jan 2022 09:46:03 -0500 Subject: Move javascript source to a subdirectory. --- js/garden.js | 699 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 js/garden.js (limited to 'js/garden.js') diff --git a/js/garden.js b/js/garden.js new file mode 100644 index 0000000..6987c57 --- /dev/null +++ b/js/garden.js @@ -0,0 +1,699 @@ +const WEEKS_BEFORE_LAST_FROST = 10; +const FALL_FACTOR = 2; + +function range(start, end) { + const size = end - start; + const array = new Array(size); + for(let i = start; i < end; i++) { + array[i - start] = i; + } + return array; +} + +function daysLater(date, n) { + return new Date(date.getTime() + n * 24 * 60 * 60 * 1000); +} + +function weeksLater(date, n) { + return daysLater(date, n * 7); +} + +function seasonLengthInWeeks(lastFrostDate, firstFrostDate) { + const oneWeek = 7 * 24 * 60 * 60 * 1000; + const dt = firstFrostDate.getTime() - lastFrostDate.getTime(); + return Math.floor(dt / oneWeek); +} + +function seasonWeeks(lastFrostDate, seasonLength) { + return range(-WEEKS_BEFORE_LAST_FROST, seasonLength + 1).map(offset => { + return { + date: weeksLater(lastFrostDate, offset), + offset: offset + }; + }); +} + +class Season { + constructor(lastFrostDate, firstFrostDate) { + this.lastFrostDate = lastFrostDate; + this.firstFrostDate = firstFrostDate; + this.length = seasonLengthInWeeks(lastFrostDate, firstFrostDate); + this.weeks = seasonWeeks(lastFrostDate, this.length); + } +} + +class Crop { + constructor(name, {fallCrop, headStart, interval, maturity, multisow, + pause, spacing, springPlant, resume, species, + transplant, trellis}) { + // Plant again in the fall? + this.fallCrop = fallCrop; + // Weeks spent indoors before transplanting. + this.headStart = headStart || 0; + // Succession planting interval (weeks) + this.interval = interval || 0; + // Average time to maturity from transplanting or sowing (weeks) + this.maturity = maturity; + // Seeds per module + this.multisow = multisow || 1; + // Human readable name + this.name = name; + // When to pause succession planting (weeks after last frost) + this.pause = pause || 99; + // When to resume succession planting (weeks before first frost) + this.resume = resume || 99; + // Plants per square foot, or, in the case of multisowing, clumps + // per square foot. + this.spacing = spacing || 1; + // Latin names. + this.species = species || []; + // Earliest sowing/transplanting week (offset relative to last + // frost date.) + this.springPlant = springPlant; + // Transplanted or direct sown? + this.transplant = transplant || false; + // Needs a trellis? + this.trellis = trellis || false; + } +} + +const CROPS = [ + new Crop("Arugula", { + interval: 2, + maturity: 6, + pause: 3, + resume: 11, + spacing: 4, + species: ["eruca sativa"], + springPlant: -4 + }), + new Crop("Basil", { + headStart: 8, + maturity: 6, + species: ["ocimum basilicum"], + springPlant: 2, + transplant: true + }), + new Crop("Bean (Bush)", { + interval: 2, + maturity: 8, + spacing: 9, + species: ["phaseolus vulgaris"], + springPlant: 1 + }), + new Crop("Bean (Pole)", { + maturity: 9, + spacing: 8, + species: ["phaseolus vulgaris"], + springPlant: 1, + trellis: true + }), + new Crop("Beet", { + interval: 2, + maturity: 8, + multisow: 4, + pause: 1, + resume: 12, + spacing: 4, + species: ["beta vulgaris"], + springPlant: -3 + }), + new Crop("Bok Choy", { + fallCrop: true, + headStart: 4, + maturity: 7, + spacing: 4, + species: ["brassica rapa"], + springPlant: -2 + }), + new Crop("Broccoli", { + fallCrop: true, + headStart: 4, + maturity: 12, + species: ["brassica oleracea"], + springPlant: -3, + transplant: true + }), + new Crop("Brussels Sprouts", { + headStart: 4, + fallCrop: true, + maturity: 16, + species: ["brassica oleracea"], + transplant: true + }), + new Crop("Cabbage (Early)", { + headStart: 4, + maturity: 6, + species: ["brassica oleracea"], + springPlant: -3, + transplant: true + }), + new Crop("Cabbage (Late)", { + fallCrop: true, + headStart: 4, + maturity: 14, + species: ["brassica oleracea"], + springPlant: -3, + transplant: true + }), + new Crop("Cabbage (Asian)", { + fallCrop: true, + headStart: 4, + maturity: 10, + species: ["brassica rapa"], + springPlant: -3, + transplant: true + }), + new Crop("Cauliflower", { + fallCrop: true, + headStart: 4, + maturity: 10, + species: ["brassica oleracea"], + springPlant: -3, + transplant: true + }), + new Crop("Carrot", { + interval: 2, + maturity: 10, + spacing: 16, + species: ["daucus carota"], + springPlant: -4 + }), + new Crop("Cilantro", { + interval: 2, + maturity: 7, + species: ["coriandrum sativum"], + springPlant: 0 + }), + new Crop("Claytonia", { + fallCrop: true, + maturity: 6, + spacing: 9, + species: ["montia perfoliata"], + springPlant: -5 + }), + new Crop("Cucumber (Vine)", { + maturity: 9, + spacing: 2, + species: ["cucumis sativus"], + springPlant: 1, + trellis: true + }), + new Crop("Dill", { + interval: 4, + maturity: 8, + species: ["anethum graveolens"], + springPlant: 0 + }), + new Crop("Eggplant", { + headStart: 8, + maturity: 11, + species: ["solanum melongena"], + springPlant: 3, + transplant: true + }), + new Crop("Endive", { + headStart: 6, + maturity: 4, + species: ["cichorium endivia"], + springPlant: -3, + transplant: true + }), + new Crop("Fennel", { + fallCrop: 'sow', + maturity: 10, + species: ["foeniculum vulgare"], + springPlant: 0 + }), + new Crop("Kale", { + fallCrop: true, + headStart: 4, + maturity: 4, + species: ["brassica napus", "brassica oleracea"], + springPlant: -4, + transplant: true + }), + new Crop("Lettuce (Leaf/Mini Head)", { + headStart: 4, + interval: 2, + maturity: 7, + pause: 3, + resume: 11, + spacing: 4, + species: ["latuca sativa"], + springPlant: -4, + transplant: true + }), + new Crop("Mache (Corn Salad)", { + interval: 2, + maturity: 7, + pause: 1, + resume: 11, + spacing: 16, + species: ["valerianella locusta"], + springPlant: -4 + }), + new Crop("Mustard (Asian Greens)", { + headStart: 3, + interval: 2, + maturity: 6, + pause: 3, + resume: 10, + spacing: 4, + species: ["brassica juncea", "brassica rapa"], + springPlant: -4, + transplant: true + }), + new Crop("Onion", { + headStart: 6, + maturity: 14, + multisow: 3, + spacing: 4, + species: ["allium cepa"], + springPlant: -4, + transplant: true + }), + new Crop("Parsley", { + headStart: 6, + maturity: 7, + spacing: 4, + species: ["petroselinum crispum"], + springPlant: -4, + transplant: true + }), + new Crop("Parsnip", { + maturity: 14, + spacing: 4, + species: ["pastinaca sativa"], + springPlant: -2 + }), + new Crop("Pea", { + fallCrop: 'sow', + maturity: 12, + spacing: 8, + species: ["pisum sativum"], + springPlant: -4, + trellis: true + }), + new Crop("Pepper", { + headStart: 8, + maturity: 10, + species: ["capsicum annuum"], + springPlant: 3, + transplant: true + }), + new Crop("Potato", { + maturity: 12, + spacing: 4, + species: ["solanum tuberosum"], + springPlant: -3 + }), + new Crop("Radish (Round)", { + interval: 2, + maturity: 4, + multisow: 4, + pause: 6, + resume: 10, + spacing: 4, + species: ["raphanus raphanistrum"], + springPlant: -3 + }), + new Crop("Radish (Daikon)", { + fallCrop: true, + maturity: 9, + spacing: 4, + species: ["raphanus raphanistrum"] + }), + new Crop("Shallot", { + headStart: 6, + maturity: 9, + multisow: 3, + spacing: 4, + species: ["allium cepa"], + springPlant: -4, + transplant: true + }), + new Crop("Spinach", { + interval: 2, + maturity: 6, + multisow: 4, + pause: -1, + resume: 11, + spacing: 4, + species: ["spinacia olerace"], + springPlant: -5 + }), + new Crop("Squash (Summer)", { + maturity: 8, + spacing: 1.0 / 9, + species: ["cucurbita pepo"], + springPlant: 0 + }), + new Crop("Squash (Winter)", { + maturity: 12, + spacing: 0.5, + species: ["cucurbita maxima", "cucurbita moschata", "cucurbita pepo"], + springPlant: 1 + }), + new Crop("Swiss Chard", { + headStart: 4, + maturity: 4, + spacing: 4, + species: ["beta vulgaris"], + springPlant: -3, + transplant: true + }), + new Crop("Tomato (Indeterminate)", { + headStart: 7, + maturity: 11, + species: ["lycopersicon esculentum"], + springPlant: 3, + transplant: true, + trellis: true + }), + new Crop("Turnip (Salad)", { + interval: 2, + maturity: 5, + multisow: 4, + pause: 6, + resume: 10, + spacing: 16, + species: ["brassica rapa"], + springPlant: -3 + }) +]; + +function makeCropSchedule(crop, season) { + function isSuccession(offset) { + const start = offset <= crop.pause ? crop.springPlant : crop.resume; + return crop.interval > 0 && + offset >= crop.springPlant && + offset <= fallPlant && + (offset <= crop.pause || offset >= resume) && + (offset - start) % crop.interval == 0; + } + + const springStart = crop.springPlant - crop.headStart; + const fallPlant = season.length - crop.maturity - FALL_FACTOR; + const fallStart = fallPlant - crop.headStart; + const resume = season.length - crop.resume; + + return season.weeks.map(week => { + function action(type) { + const date = weeksLater(season.lastFrostDate, week.offset); + return { + date: date, + actions: type ? [type] : [] + }; + } + + if(crop.transplant && week.offset == springStart) { + return action("start"); + } else if(crop.transplant && week.offset == crop.springPlant) { + return action("transplant"); + } else if(week.offset == crop.springPlant) { + return action("sow"); + } else if(isSuccession(week.offset)) { + return action("sow"); + } else if(crop.fallCrop && crop.transplant && week.offset == fallStart) { + return action("start"); + } else if(crop.fallCrop && crop.transplant && week.offset == fallPlant) { + return action("transplant"); + } else if(crop.fallCrop && week.offset == fallPlant) { + return action("sow"); + } + + return action(null); + }); +} + +function clearElement(element) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } +} + +function makeSchedule(crops, season) { + const schedule = {}; + crops.forEach(crop => { + schedule[crop.name] = makeCropSchedule(crop, season); + }); + return schedule; +} + +function monthAndDay(date) { + return (date.getUTCMonth() + 1) + "/" + date.getUTCDate(); +} + +function viewCropDetails(crop) { + function close() { + container.removeChild(overlay); + } + + function addDetail(name, detail) { + const p = document.createElement("p"); + p.appendChild(document.createTextNode(`${name}: ${detail}`)); + modal.appendChild(p); + } + + const container = document.getElementById("container"); + const overlay = document.createElement("div"); + const modal = document.createElement("div"); + const heading = document.createElement("h2"); + const subheading = document.createElement("h4"); + const speciesDetail = `(${crop.species.join(", ")})`; + heading.appendChild(document.createTextNode(crop.name)); + subheading.appendChild(document.createTextNode(speciesDetail)); + subheading.classList.add("detail-subheading"); + modal.classList.add("modal"); + modal.appendChild(heading); + modal.appendChild(subheading); + addDetail("Average time to maturity", + `${crop.maturity} weeks from ${crop.transplant ? "transplant" : "seed"}`); + if(crop.springPlant == -1) { + addDetail("Spring planting time", `1 week before last frost`); + } else if(crop.springPlant < 0) { + addDetail("Spring planting time", + `${Math.abs(crop.springPlant)} weeks before last frost`); + } else if(crop.springPlant == 1) { + addDetail("Spring planting time", `1 week after last frost`); + } else if(crop.springPlant > 0) { + addDetail("Spring planting time", + `${crop.springPlant} weeks after last frost`); + } else if(crop.springPlant == 0) { + addDetail("Spring planting time", "On the last frost date"); + } else { + addDetail("Spring planting time", "Not a spring crop"); + } + if(crop.fallCrop) { + addDetail("Fall planting time", `${crop.maturity + FALL_FACTOR} weeks before first frost`); + } else if(crop.resume != 99) { + addDetail("Fall planting time", `Succession planted ${crop.resume} weeks before first frost`); + } else if(crop.interval > 0) { + addDetail("Fall planting time", `Succession planted until ${crop.maturity + FALL_FACTOR} weeks before first frost`); + } else { + addDetail("Fall planting time", "Not a fall crop"); + } + addDetail("Planting method", crop.transplant ? "Transplant" : "Direct sow"); + if(crop.transplant) { + addDetail("Start indoors", `${crop.headStart} weeks before planting`); + } + if(crop.interval > 0) { + addDetail("Succession planting interval", `Every ${crop.interval} weeks`); + } + if(crop.multisow > 1) { + addDetail("Multisow", `${crop.multisow} seeds per module`); + } else { + addDetail("Multisow", "No"); + } + if(crop.spacing >= 1) { + addDetail("Spacing", `${crop.spacing} ${crop.multisow > 1 ? "clumps" : "plants"} per square foot`); + } else { + addDetail("Spacing", `1 per ${Math.floor(1 / crop.spacing)} square feet`); + } + addDetail("Needs a trellis", crop.trellis ? "Yes" : "No"); + overlay.classList.add("overlay"); + overlay.appendChild(modal); + container.appendChild(overlay); + overlay.addEventListener("click", event => close()); +} + +function makeDetailLink(crop) { + const span = document.createElement("span"); + span.classList.add("crop"); + span.appendChild(document.createTextNode(crop.name)); + span.addEventListener("click", event => { + viewCropDetails(crop); + }); + return span; +} + +function refreshTable(crops, season, schedule) { + function createWeeksRow(season) { + const row = document.createElement("tr"); + row.appendChild(document.createElement("th")); + season.weeks.forEach(week => { + const td = document.createElement("th", {scope: "col"}); + const firstFrost = week.offset == 0; + const lastFrost = week.offset == season.length; + const dateString = monthAndDay(week.date); + const label = firstFrost || lastFrost ? `${dateString}❄`: dateString; + const text = document.createTextNode(label); + td.appendChild(text); + row.appendChild(td); + }); + return row; + } + + function createHeader(season) { + const thead = document.createElement("thead"); + const row = document.createElement("tr"); + ["Crop", "Week"].forEach(column => { + const th = document.createElement("th", {scope: "col"}); + const text = document.createTextNode(column); + th.appendChild(text); + row.appendChild(th); + }); + thead.appendChild(row); + thead.appendChild(createWeeksRow(season)); + return thead; + } + + function createCropRow(crop, schedule) { + function icon(name) { + const img = document.createElement("img"); + img.classList.add("icon"); + img.src = `images/${name}.svg`; + return img; + } + + const row = document.createElement("tr"); + const th = document.createElement("td"); + th.appendChild(makeDetailLink(crop)); + row.appendChild(th); + schedule.forEach(week => { + const td = document.createElement("td"); + td.title = monthAndDay(week.date); + week.actions.forEach(action => { + const iconName = { + start: "start-indoors", + sow: "direct-sow", + transplant: "transplant" + }[action]; + td.appendChild(icon(iconName)); + }); + row.appendChild(td); + }); + return row; + } + + const scheduleTable = document.getElementById("schedule"); + const tbody = document.createElement("tbody"); + clearElement(scheduleTable); + scheduleTable.appendChild(createHeader(season)); + crops.forEach(crop => { + tbody.appendChild(createCropRow(crop, schedule[crop.name])); + }); + scheduleTable.appendChild(tbody); +} + +function refreshInstructions(crops, season, schedule) { + const instructionsDiv = document.getElementById("instructions"); + season.weeks.forEach(week => { + const actions = crops.flatMap(crop => { + const i = week.offset + WEEKS_BEFORE_LAST_FROST; + return schedule[crop.name][i].actions.map(action => { + return { + crop: crop, + type: action + }; + }); + }).sort((a, b) => { + if(a.type < b.type) { + return -1; + } else if(a.type > b.type) { + return 1; + } + + return 0; + });; + + if(actions.length > 0) { + const weekDiv = document.createElement("div"); + const heading = document.createElement("h3"); + heading.appendChild(document.createTextNode(monthAndDay(week.date))); + weekDiv.appendChild(heading); + actions.forEach(action => { + const p = document.createElement("p"); + let prefix; + let suffix; + if(action.type == "start") { + prefix = "Start "; + suffix = " indoors."; + } else if(action.type == "sow") { + prefix = "Sow "; + suffix = " outdoors."; + } else if(action.type == "transplant") { + prefix = "Transplant "; + suffix = " outdoors."; + } + p.appendChild(document.createTextNode(prefix)); + p.appendChild(makeDetailLink(action.crop)); + p.appendChild(document.createTextNode(suffix)); + weekDiv.appendChild(p); + }); + instructionsDiv.appendChild(weekDiv); + } + }); +} + +function refreshView() { + function loadDate(name) { + const dateString = window.localStorage.getItem(name); + return dateString && new Date(Date.parse(dateString)); + } + + const lastFrostDate = loadDate("lastFrostDate"); + const firstFrostDate = loadDate("firstFrostDate"); + + if(firstFrostDate && lastFrostDate) { + const season = new Season(lastFrostDate, firstFrostDate); + const schedule = makeSchedule(CROPS, season); + refreshTable(CROPS, season, schedule); + refreshInstructions(CROPS, season, schedule); + } +} + +function getLastFrostDateInput() { + return document.querySelector("input[name='last-frost-date']"); +} + +function getFirstFrostDateInput() { + return document.querySelector("input[name='first-frost-date']"); +} + +function loadConfiguration() { + const lastFrostDate = window.localStorage.getItem("lastFrostDate"); + const firstFrostDate = window.localStorage.getItem("firstFrostDate"); + if(lastFrostDate) { + getLastFrostDateInput().value = lastFrostDate; + } + if(firstFrostDate) { + getFirstFrostDateInput().value = firstFrostDate; + } +} + +function loadIntensiveGarden() { + loadConfiguration(); + getLastFrostDateInput().addEventListener("input", event => { + window.localStorage.setItem("lastFrostDate", event.target.value); + refreshView(); + }); + getFirstFrostDateInput().addEventListener("input", event => { + window.localStorage.setItem("firstFrostDate", event.target.value); + refreshView(); + }); + refreshView(); +} + +window.addEventListener("load", loadIntensiveGarden); -- cgit v1.2.3