summaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorDavid Thompson <dthompson@vistahigherlearning.com>2022-01-22 09:46:03 -0500
committerDavid Thompson <dthompson@vistahigherlearning.com>2022-01-22 09:46:32 -0500
commitc0da8d22862a0afca41ca052c3424564b0011dda (patch)
treeb2222f54d7b367e61a6482ee91cc25a6f3f178ac /js
parent0aa29be84d4c554ef0d9dddc2d1f0f24859f587d (diff)
Move javascript source to a subdirectory.
Diffstat (limited to 'js')
-rw-r--r--js/garden.js699
1 files changed, 699 insertions, 0 deletions
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);