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. --- garden.js | 699 ----------------------------------------------------------- index.html | 2 +- js/garden.js | 699 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+), 700 deletions(-) delete mode 100644 garden.js create mode 100644 js/garden.js diff --git a/garden.js b/garden.js deleted file mode 100644 index 6987c57..0000000 --- a/garden.js +++ /dev/null @@ -1,699 +0,0 @@ -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); diff --git a/index.html b/index.html index cba12e7..00ef0bc 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + 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