diff --git a/global.d.ts b/global.d.ts index 7b8495b..53b9e10 100644 --- a/global.d.ts +++ b/global.d.ts @@ -19,5 +19,6 @@ declare global { achievement: number ease: number impact: number + color: string } } diff --git a/index.htm b/index.htm index 0d9056c..829e59e 100644 --- a/index.htm +++ b/index.htm @@ -15,6 +15,7 @@ + diff --git a/main.js b/main.js index aa00b41..562050d 100644 --- a/main.js +++ b/main.js @@ -12,13 +12,65 @@ if (!(addButton instanceof HTMLButtonElement)) { throw new Error("button not found!") } +const defaultColors = [ + "#0072B2", // Strong Blue + "#009E73", // Teal Green + "#D55E00", // Vermilion + "#E69F00", // Vibrant Orange + "#EEDD88", // Pale Yellow + "#CC79A7", // Purple + "#56B4E9", // Sky Blue + "#999999", // Medium Grey + "#88CCEE", // Soft Cyan + "#44AA99", // Dark Teal + "#117733", // Forest Green + "#DDCC77", // Mustard + "#661100", // Deep Brown + "#AA4499", // Plum + "#882255", // Burgundy + "#CC6677", // Rose + "#DDDDDD", // Light Grey + "#114477", // Steely Blue + "#999933", // Olive + "#AA3377", // Orchid + "#77AADD", // Muted Blue + "#EE8866", // Coral + "#44BB99", // Aquamarine + "#9988DD", // Soft Purple + "#EECC66", // Sand + "#FF6F00", // Flame + "#C51B8A", // Fuchsia + "#F0E442", // Bright Yellow + "#1B9E77", // Dark Teal + "#7570B3", // Blue-Purple + "#E7298A", // Magenta + "#66A61E", // Leaf Green + "#E6AB02", // Warm Yellow + "#A6761D", // Ochre + "#666666", // Dark Grey +] + +/** @type {(color: unknown) => boolean} */ +const isValidColor = (color) => + typeof color === "string" && /^#[0-9a-fA-F]{6}$/.test(color) + +/** @type {(currentObjectives: Objective[]) => string} */ +const findNextAvailableColor = (objectiveList) => + defaultColors.find( + (color) => + !objectiveList.some( + (x) => String(x.color).toUpperCase() === color.toUpperCase(), + ), + ) || "#000000" + /** @type {(index: number) => Objective} */ const newObjective = (index) => ({ - name: `Objective ${index}`, + name: `Objective ${index + 1}`, fun: 0.5, achievement: 0.5, ease: 0.5, impact: 0.5, + color: "#000000", }) /** @type {() => Point} */ @@ -46,6 +98,8 @@ const generateObjectiveHTML = (index, objective) => { return String(objective.ease) case "impact": return String(objective.impact) + case "color": + return objective.color default: return "" } @@ -56,30 +110,32 @@ const generateObjectiveHTML = (index, objective) => { } /** @type {Objective[]} */ -let objectiveList = [] +let activeObjectiveList = [] const loadObjectives = () => { // Try to get the objectives from localStorage. try { - objectiveList = JSON.parse(localStorage.getItem("objectives") || "[]") + activeObjectiveList = JSON.parse(localStorage.getItem("objectives") || "[]") } catch { /* Do nothing on failure. */ } - for (const [index, objective] of objectiveList.entries()) { - addObjectiveToDOM(index, objective) + for (const objective of activeObjectiveList) { + // If colors are missing or invalid find a suitable one. + if (!isValidColor(objective.color)) { + objective.color = findNextAvailableColor(activeObjectiveList) + } } - if (objectiveList.length === 0) { - // Add an objective straight away if we have none. - createAndRenderNewObjective() + for (const [index, objective] of activeObjectiveList.entries()) { + addObjectiveToDOM(index, objective) } } const saveObjectives = () => { // Try to persist objectives to localStorage. try { - localStorage.setItem("objectives", JSON.stringify(objectiveList)) + localStorage.setItem("objectives", JSON.stringify(activeObjectiveList)) } catch { /* Do nothing on failure. */ } @@ -94,24 +150,25 @@ const addObjectiveToDOM = (index, objective) => { } const createAndRenderNewObjective = () => { - const index = objectiveList.length + const index = activeObjectiveList.length const objective = newObjective(index) + objective.color = findNextAvailableColor(activeObjectiveList) - objectiveList.push(objective) + activeObjectiveList.push(objective) addObjectiveToDOM(index, objective) saveObjectives() } -/** @type {(foo: {text: string, data: Point[], labels: string[], xTitle: string, yTitle: string}) => ChartConfiguration} */ -const createChartConfig = ({text, data, labels, xTitle, yTitle}) => ({ +/** @type {(foo: {text: string, data: Point[], labels: string[], colors: string[], xTitle: string, yTitle: string}) => ChartConfiguration} */ +const createChartConfig = ({text, data, labels, colors, xTitle, yTitle}) => ({ type: "scatter", data: { labels, datasets: [ { data: data, - borderColor: "rgb(255, 99, 132)", - backgroundColor: "rgba(255, 99, 132, 0.5)", + borderColor: colors, + backgroundColor: colors, }, ], }, @@ -167,10 +224,13 @@ const renderCharts = () => { const combinedData = [] /** @type {string[]} */ const labels = [] + /** @type {string[]} */ + const colors = [] // Map objectives to labels and coordinates to render. - for (const objective of objectiveList.values()) { + for (const objective of activeObjectiveList.values()) { labels.push(objective.name) + colors.push(objective.color) personalData.push({ x: objective.achievement, y: objective.fun, @@ -200,6 +260,7 @@ const renderCharts = () => { xTitle: "Achievement", yTitle: "Fun", labels, + colors, }), ) } else { @@ -207,6 +268,8 @@ const renderCharts = () => { if (personalChart.data.datasets) { personalChart.data.datasets[0].data = personalData + personalChart.data.datasets[0].borderColor = colors + personalChart.data.datasets[0].backgroundColor = colors } personalChart.update() @@ -226,6 +289,7 @@ const renderCharts = () => { xTitle: "Impact", yTitle: "Ease", labels, + colors, }), ) } else { @@ -233,6 +297,8 @@ const renderCharts = () => { if (collectiveChart.data.datasets) { collectiveChart.data.datasets[0].data = collectiveData + collectiveChart.data.datasets[0].borderColor = colors + collectiveChart.data.datasets[0].backgroundColor = colors } collectiveChart.update() @@ -252,6 +318,7 @@ const renderCharts = () => { xTitle: "Collective", yTitle: "Personal", labels, + colors, }), ) } else { @@ -259,6 +326,8 @@ const renderCharts = () => { if (combinedChart.data.datasets) { combinedChart.data.datasets[0].data = combinedData + combinedChart.data.datasets[0].borderColor = colors + combinedChart.data.datasets[0].backgroundColor = colors } combinedChart.update() @@ -266,6 +335,7 @@ const renderCharts = () => { } const updateObjectiveListFromHTML = () => { + // Create a new objective list for replacing the current one. /** @type {Objective[]} */ const newObjectiveList = [] @@ -277,6 +347,7 @@ const updateObjectiveListFromHTML = () => { const index = Number(key.match(/\d+/)?.[0]) if (typeof value === "string" && Number.isFinite(index)) { + // Create a new objective. We must not read the activeObjectiveList. const objective = newObjectiveList[index] || newObjective(index) // PC co-ordinates are (C, P) combined, spread is (I, E, A, F) @@ -292,11 +363,15 @@ const updateObjectiveListFromHTML = () => { objective[fieldName] = value newObjectiveList[index] = objective break + case "color": + objective.color = value + newObjectiveList[index] = objective + break } } } - objectiveList = newObjectiveList + activeObjectiveList = newObjectiveList } const persistAndRenderObjectives = () => { @@ -332,7 +407,7 @@ pcForm.addEventListener("click", (event) => { ) { // Pick out the delete button index and remove the element. const index = Number(event.target.name.slice("delete".length)) - objectiveList.splice(index, 1) + activeObjectiveList.splice(index, 1) persistAndRenderObjectives() } }) @@ -349,14 +424,14 @@ if (!(downloadCSVButton instanceof HTMLButtonElement)) { /** @type {() => void} */ const saveCSV = () => { // Convert data into CSV. - const objectiveCSVData = objectiveList + const objectiveCSVData = activeObjectiveList .map( (objective) => `${objective.name.replace(",", "")},${objective.achievement},` + - `${objective.fun},${objective.impact},${objective.ease}`, + `${objective.fun},${objective.impact},${objective.ease},${objective.color}`, ) .join("\n") - const csv = `name,achievement,fun,impact,ease\n${objectiveCSVData}` + const csv = `name,achievement,fun,impact,ease,color\n${objectiveCSVData}` const blob = new Blob([csv], {type: "text/csv;charset=utf-8;"}) @@ -389,7 +464,7 @@ const parseObjectiveNumber = (text) => { /** @type {(text: string) => void} */ const loadCSV = (text) => { - // name,achievement,fun,impact,ease + // name,achievement,fun,impact,ease,color const rows = text.split("\n").map((line) => line.split(",")) const header = rows[0] || [] // Remove the header row now we've pulled it out. @@ -400,6 +475,7 @@ const loadCSV = (text) => { const funIndex = header.indexOf("fun") const impactIndex = header.indexOf("impact") const easeIndex = header.indexOf("ease") + const colorIndex = header.indexOf("color") /** @type {Objective[]} */ const newObjectiveList = [] @@ -411,10 +487,11 @@ const loadCSV = (text) => { fun: parseObjectiveNumber(row[funIndex]), impact: parseObjectiveNumber(row[impactIndex]), ease: parseObjectiveNumber(row[easeIndex]), + color: row[colorIndex] || "", }) } - objectiveList = newObjectiveList + activeObjectiveList = newObjectiveList persistAndRenderObjectives() } @@ -434,4 +511,8 @@ uploadCSVInput.addEventListener("change", (event) => { reader.readAsText(file) } + + if (event.target instanceof HTMLInputElement) { + event.target.value = "" + } })