diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 7064c6885..95c98a47f 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -234,6 +234,10 @@ Spiritual Father Emmanuel Ferdman Sunong2008 Marvin Kopf +David Brenn +Felix Kühne +Matthis Ehrhardt +Billy Julian Lesmana Kevin Nakamura Bradley Szoke jcznk diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 23b72f267..e7a20b36c 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -60,6 +60,7 @@ preferences-full-screen-only = Full screen only preferences-appearance = Appearance preferences-general = General preferences-style = Style +preferences-color-blind = Color Blind Mode preferences-review = Review preferences-answer-keys = Answer keys preferences-distractions = Distractions diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 0035e1f42..9df416d8e 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -104,6 +104,23 @@ preferences_user_interface + + + + preferences_style + + + styleComboBox + + + + + + + + + + @@ -123,23 +140,6 @@ - - - - - - - - - - - preferences_style - - - styleComboBox - - - @@ -160,13 +160,26 @@ - + preferences_reset_window_sizes + + + + + 0 + 0 + + + + preferences_color_blind + + + diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index afce6d489..4037b8240 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -371,11 +371,24 @@ class Preferences(QDialog): self.form.styleComboBox.setVisible(not is_win) qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes) + self.form.color_blind.setChecked(self.mw.pm.color_blind()) + qconnect( + self.form.color_blind.stateChanged, self.on_color_blind_checkbox_changed + ) + self.setup_language() self.setup_video_driver() self.setupOptions() + def on_color_blind_checkbox_changed(self, state: int) -> None: + if state == 2: + # checkbox is checked + self.mw.pm.set_color_blind(True) + else: + # checkbox is unchecked + self.mw.pm.set_color_blind(False) + def update_global(self) -> None: restart_required = False diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 919be170c..9d486d3e7 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -536,6 +536,12 @@ create table if not exists profiles def setUiScale(self, scale: float) -> None: self.meta["uiScale"] = scale + def set_color_blind(self, value: bool) -> None: + self.meta["color_blind"] = value + + def color_blind(self) -> bool: + return self.meta.get("color_blind", False) + def reduce_motion(self) -> bool: return self.meta.get("reduce_motion", True) diff --git a/qt/aqt/stats.py b/qt/aqt/stats.py index 0b8a8a3ee..4416bb988 100644 --- a/qt/aqt/stats.py +++ b/qt/aqt/stats.py @@ -136,10 +136,18 @@ class NewDeckStats(QDialog): _, query = cmd.split(":", 1) browser = aqt.dialogs.open("Browser", self.mw) browser.search_for(query) - return False def refresh(self) -> None: + def on_load_finished(success: bool) -> None: + if success: + is_color_blind = self.mw.pm.color_blind() + js_code = f"window.colorBlindMode = {str(is_color_blind).lower()};" + self.form.web.eval(js_code) + # Disconnect after running once to avoid multiple triggers + self.form.web.page().loadFinished.disconnect(on_load_finished) + + self.form.web.page().loadFinished.connect(on_load_finished) self.form.web.load_sveltekit_page("graphs") diff --git a/ts/routes/graphs/TrueRetentionCombined.svelte b/ts/routes/graphs/TrueRetentionCombined.svelte index 5a9efc814..419bb59e5 100644 --- a/ts/routes/graphs/TrueRetentionCombined.svelte +++ b/ts/routes/graphs/TrueRetentionCombined.svelte @@ -3,9 +3,9 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> @@ -84,11 +100,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } .young { - color: #64c476; + color: var(--young-color); } .mature { - color: #31a354; + color: var(--mature-color); } .total { diff --git a/ts/routes/graphs/TrueRetentionSingle.svelte b/ts/routes/graphs/TrueRetentionSingle.svelte index 9199717f8..4855c3c15 100644 --- a/ts/routes/graphs/TrueRetentionSingle.svelte +++ b/ts/routes/graphs/TrueRetentionSingle.svelte @@ -3,6 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
@@ -71,11 +87,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } .pass { - color: #3bc464; + color: var(--pass-color); } .fail { - color: #c43b3b; + color: var(--fail-color); } .retention { diff --git a/ts/routes/graphs/added.ts b/ts/routes/graphs/added.ts index f60bb03aa..d11ce24b9 100644 --- a/ts/routes/graphs/added.ts +++ b/ts/routes/graphs/added.ts @@ -8,7 +8,7 @@ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { dayLabel } from "@tslib/time"; -import type { Bin } from "d3"; +import { type Bin, interpolateViridis } from "d3"; import { bin, interpolateBlues, min, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; @@ -80,8 +80,18 @@ export function buildHistogram( return [null, []]; } - const adjustedRange = scaleLinear().range([0.7, 0.3]); - const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); + let adjustedRange; + let colourScale; + const isColourBlindMode = (window as any).colorBlindMode; + + // Changing color based on mode + if (isColourBlindMode) { + adjustedRange = scaleLinear().range([0.3, 0.7]); + colourScale = scaleSequential((n) => interpolateViridis(adjustedRange(n)!)).domain([xMax!, xMin!]); + } else { + adjustedRange = scaleLinear().range([0.7, 0.3]); + colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); + } const totalInPeriod = sum(bins, accessor); const periodDays = Math.abs(xMin!); diff --git a/ts/routes/graphs/buttons.ts b/ts/routes/graphs/buttons.ts index 606380590..1af54e33b 100644 --- a/ts/routes/graphs/buttons.ts +++ b/ts/routes/graphs/buttons.ts @@ -12,6 +12,7 @@ import { axisBottom, axisLeft, interpolateRdYlGn, + interpolateViridis, pointer, scaleBand, scaleLinear, @@ -20,7 +21,7 @@ import { sum, } from "d3"; -import type { GraphBounds } from "./graph-helpers"; +import { type GraphBounds } from "./graph-helpers"; import { GraphRange } from "./graph-helpers"; import { setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; @@ -162,7 +163,15 @@ export function renderButtons( .paddingOuter(1) .paddingInner(0.1); - const colour = scaleSequential(interpolateRdYlGn).domain([1, 4]); + const isColourBlindMode = (window as any).colorBlindMode; + let colour; + + // Changing color based on mode + if (isColourBlindMode) { + colour = scaleSequential(interpolateViridis).domain([1, 4]); + } else { + colour = scaleSequential(interpolateRdYlGn).domain([1, 4]); + } // y scale const yTickFormat = (n: number): string => localizedNumber(n); diff --git a/ts/routes/graphs/calendar.ts b/ts/routes/graphs/calendar.ts index 863923095..f8048c7af 100644 --- a/ts/routes/graphs/calendar.ts +++ b/ts/routes/graphs/calendar.ts @@ -10,6 +10,7 @@ import type { CountableTimeInterval } from "d3"; import { timeHour } from "d3"; import { interpolateBlues, + interpolateMagma, pointer, scaleLinear, scaleSequentialSqrt, @@ -136,10 +137,21 @@ export function renderCalendar( } } const data = Array.from(dayMap.values()); - const cappedRange = scaleLinear().range([0.2, nightMode ? 0.8 : 1]); - const blues = scaleSequentialSqrt() - .domain([0, maxCount]) - .interpolator((n) => interpolateBlues(cappedRange(n)!)); + const cappedRange = scaleLinear().range([1, 0]); + + const isColorBlindMode = (window as any).colorBlindMode; + + let gradient; + + if (isColorBlindMode) { + gradient = scaleSequentialSqrt() + .domain([0, maxCount]) + .interpolator((n) => interpolateMagma(cappedRange(n)!)); + } else { + gradient = scaleSequentialSqrt() + .domain([0, maxCount]) + .interpolator((n) => interpolateBlues(cappedRange(n)!)); + } function tooltipText(d: DayDatum): string { const date = localizedDate(d.date, { @@ -203,7 +215,7 @@ export function renderCalendar( }) .transition() .duration(800) - .attr("fill", (d: DayDatum) => (d.count === 0 ? emptyColour : blues(d.count)!)); + .attr("fill", (d: DayDatum) => (d.count === 0 ? emptyColour : gradient(d.count)!)); } function timeFunctionForDay(firstDayOfWeek: Weekday): CountableTimeInterval { diff --git a/ts/routes/graphs/card-counts.ts b/ts/routes/graphs/card-counts.ts index e31c0a5b9..157ab3356 100644 --- a/ts/routes/graphs/card-counts.ts +++ b/ts/routes/graphs/card-counts.ts @@ -22,7 +22,7 @@ import { sum, } from "d3"; -import type { GraphBounds } from "./graph-helpers"; +import { colorBlindColors, type GraphBounds } from "./graph-helpers"; type Count = [string, number, boolean, string]; export interface GraphData { @@ -31,15 +31,29 @@ export interface GraphData { totalCards: string; } -const barColours = [ - schemeBlues[5][2], /* new */ - schemeOranges[5][2], /* learn */ - schemeReds[5][2], /* relearn */ - schemeGreens[5][2], /* young */ - schemeGreens[5][3], /* mature */ - "#FFDC41", /* suspended */ - "grey", /* buried */ -]; +let barColours; + +if ((window as any).colorBlindMode) { + barColours = [ + colorBlindColors.new, /* new */ + colorBlindColors.learn, /* learn */ + colorBlindColors.relearn, /* relearn */ + colorBlindColors.young, /* young */ + colorBlindColors.mature, /* mature */ + colorBlindColors.suspended, /* suspended */ + colorBlindColors.buried, /* buried */ + ]; +} else { + barColours = [ + schemeBlues[5][2], /* new */ + schemeOranges[5][2], /* learn */ + schemeReds[5][2], /* relearn */ + schemeGreens[5][2], /* young */ + schemeGreens[5][3], /* mature */ + "#FFDC41", /* suspended */ + "#grey", /* buried */ + ]; +} function countCards(data: GraphsResponse, separateInactive: boolean): Count[] { const countData = separateInactive ? data.cardCounts!.excludingInactive! : data.cardCounts!.includingInactive!; diff --git a/ts/routes/graphs/ease.ts b/ts/routes/graphs/ease.ts index 26de02096..446c780ea 100644 --- a/ts/routes/graphs/ease.ts +++ b/ts/routes/graphs/ease.ts @@ -9,7 +9,7 @@ import type { GraphsResponse } from "@generated/anki/stats_pb"; import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import type { Bin, ScaleLinear } from "d3"; -import { bin, extent, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3"; +import { bin, extent, interpolateRdYlGn, interpolateViridis, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, numericMap } from "./graph-helpers"; @@ -84,7 +84,13 @@ export function prepareData( .thresholds(ticks)(allEases.entries() as any); const total = sum(bins as any, getNumericMapBinValue); - const colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]); + let colourScale; + + if ((window as any).colorBlindMode) { + colourScale = scaleSequential(interpolateViridis).domain([xMin, 300]); + } else { + colourScale = scaleSequential(interpolateRdYlGn).domain([xMin, 300]); + } function hoverText(bin: Bin, _percent: number): string { const minPct = Math.floor(bin.x0!); diff --git a/ts/routes/graphs/future-due.ts b/ts/routes/graphs/future-due.ts index 6100470b7..2b7b74862 100644 --- a/ts/routes/graphs/future-due.ts +++ b/ts/routes/graphs/future-due.ts @@ -10,7 +10,7 @@ import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { dayLabel } from "@tslib/time"; import type { Bin } from "d3"; -import { bin, extent, interpolateGreens, scaleLinear, scaleSequential, sum } from "d3"; +import { bin, extent, interpolateGreens, interpolateViridis, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { getNumericMapBinValue, GraphRange, numericMap } from "./graph-helpers"; @@ -108,8 +108,20 @@ export function buildHistogram( } const xTickFormat = (n: number): string => localizedNumber(n); - const adjustedRange = scaleLinear().range([0.7, 0.3]); - const colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]); + + let adjustedRange = scaleLinear().range([0.0, 1]); + + const isColorBlindMode = (window as any).colorBlindMode; + + let colourScale; + + if (isColorBlindMode) { + colourScale = scaleSequential((n) => interpolateViridis(adjustedRange(n)!)).domain([xMin!, xMax!]); + adjustedRange = scaleLinear().range([0.0, 1]); + } else { + colourScale = scaleSequential((n) => interpolateGreens(adjustedRange(n)!)).domain([xMin!, xMax!]); + adjustedRange = scaleLinear().range([0.7, 0.3]); + } const total = sum(bins as any, getNumericMapBinValue); diff --git a/ts/routes/graphs/graph-helpers.ts b/ts/routes/graphs/graph-helpers.ts index 47f0249b4..148b2843b 100644 --- a/ts/routes/graphs/graph-helpers.ts +++ b/ts/routes/graphs/graph-helpers.ts @@ -105,3 +105,17 @@ export function numericMap(obj: { [k: string]: T }): Map { export function getNumericMapBinValue(d: Bin, number>): number { return sum(d, (d) => d[1]); } + +/** + * Colorblind-friendly colors from https://davidmathlogic.com/colorblind/ + */ +export const colorBlindColors = { + new: "#88CCEE", + learn: "#44AA99", + relearn: "#117733", + young: "#CC6677", + mature: "#882255", + suspended: "#DDCC77", + buried: "#332288", + filtered: "#AA4499", +}; diff --git a/ts/routes/graphs/hours.ts b/ts/routes/graphs/hours.ts index 3f3b07771..9c9e57a27 100644 --- a/ts/routes/graphs/hours.ts +++ b/ts/routes/graphs/hours.ts @@ -16,6 +16,7 @@ import { axisRight, curveBasis, interpolateBlues, + interpolateViridis, pointer, scaleBand, scaleLinear, @@ -83,11 +84,23 @@ export function renderHours( .classed(oddTickClass, (d: any): boolean => d % 2 != 0) .attr("direction", "ltr"); - const cappedRange = scaleLinear().range([0.1, 0.8]); - const colour = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([ - 0, - yMax, - ]); + let cappedRange; + let colour; + const isColorBlindMode = (window as any).colorBlindMode; + + if (isColorBlindMode) { + cappedRange = scaleLinear().range([0.0, 1]); + colour = scaleSequential((n) => interpolateViridis(cappedRange(n)!)).domain([ + 0, + yMax, + ]); + } else { + cappedRange = scaleLinear().range([0.1, 0.8]); + colour = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain([ + 0, + yMax, + ]); + } // y scale const yTickFormat = (n: number): string => localizedNumber(n); diff --git a/ts/routes/graphs/intervals.ts b/ts/routes/graphs/intervals.ts index ffd41b238..ad5121163 100644 --- a/ts/routes/graphs/intervals.ts +++ b/ts/routes/graphs/intervals.ts @@ -10,7 +10,7 @@ import * as tr from "@generated/ftl"; import { localizedNumber } from "@tslib/i18n"; import { timeSpan } from "@tslib/time"; import type { Bin } from "d3"; -import { bin, extent, interpolateBlues, quantile, scaleLinear, scaleSequential, sum } from "d3"; +import { bin, extent, interpolateBlues, interpolateViridis, quantile, scaleLinear, scaleSequential, sum } from "d3"; import type { SearchDispatch, TableDatum } from "./graph-helpers"; import { numericMap } from "./graph-helpers"; @@ -147,8 +147,16 @@ export function prepareIntervalData( return [null, []]; } - const adjustedRange = scaleLinear().range([0.7, 0.3]); - const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); + let adjustedRange; + let colourScale; + + if ((window as any).colorBlindMode) { + adjustedRange = scaleLinear().range([0.3, 0.7]); + colourScale = scaleSequential((n) => interpolateViridis(adjustedRange(n)!)).domain([xMax!, xMin!]); + } else { + adjustedRange = scaleLinear().range([0.7, 0.3]); + colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]); + } function hoverText( bin: Bin, diff --git a/ts/routes/graphs/reviews.ts b/ts/routes/graphs/reviews.ts index ed0e07f8c..1d274d56d 100644 --- a/ts/routes/graphs/reviews.ts +++ b/ts/routes/graphs/reviews.ts @@ -16,12 +16,15 @@ import { axisLeft, axisRight, bin, + color, cumsum, curveBasis, + hsl, interpolateGreens, interpolateOranges, interpolatePurples, interpolateReds, + interpolateRgb, max, min, pointer, @@ -31,7 +34,7 @@ import { sum, } from "d3"; -import type { GraphBounds, TableDatum } from "./graph-helpers"; +import { colorBlindColors, type GraphBounds, type TableDatum } from "./graph-helpers"; import { GraphRange, numericMap, setDataAvailable } from "./graph-helpers"; import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; @@ -188,18 +191,47 @@ export function renderReviews( x.domain() as any, ); + const colorBlindMode = (window as any).colorBlindMode; + + function makeColorBlindGradient(baseHex: string, satAdjust = 0.02, lightAdjust = 0.02) { + const base = color(baseHex); + if (!base) { throw new Error(`Invalid color: ${baseHex}`); } + + const lighter = hsl(base); + lighter.s = Math.min(1, lighter.s + satAdjust); + lighter.l = Math.min(1, lighter.l + lightAdjust); + + const darker = hsl(base); + darker.s = Math.max(0, darker.s - satAdjust); + darker.l = Math.max(0, darker.l - lightAdjust); + + return scaleSequential(interpolateRgb(darker.toString(), lighter.toString())); + } + + const colorBlindScales = { + mature: makeColorBlindGradient(colorBlindColors.mature), + learn: makeColorBlindGradient(colorBlindColors.learn), + relearn: makeColorBlindGradient(colorBlindColors.relearn), + young: makeColorBlindGradient(colorBlindColors.young), + suspended: makeColorBlindGradient(colorBlindColors.suspended), + buried: makeColorBlindGradient(colorBlindColors.buried), + filtered: makeColorBlindGradient(colorBlindColors.filtered), + }; + + Object.values(colorBlindScales).forEach(scale => scale.domain(x.domain() as any)); + function binColor(idx: BinIndex): ScaleSequential { switch (idx) { case BinIndex.Mature: - return darkerGreens; + return colorBlindMode ? colorBlindScales.mature : darkerGreens; case BinIndex.Young: - return lighterGreens; + return colorBlindMode ? colorBlindScales.young : lighterGreens; case BinIndex.Learn: - return oranges; + return colorBlindMode ? colorBlindScales.learn : oranges; case BinIndex.Relearn: - return reds; + return colorBlindMode ? colorBlindScales.relearn : reds; case BinIndex.Filtered: - return purples; + return colorBlindMode ? colorBlindScales.filtered : purples; } }