diff --git a/apps/gamut-mapping/gradients.css b/apps/gamut-mapping/gradients.css
new file mode 100644
index 00000000..8dab6c0c
--- /dev/null
+++ b/apps/gamut-mapping/gradients.css
@@ -0,0 +1,83 @@
+:root {
+ --font-mono: Consolas, Inconsolata, Monaco, monospace;
+}
+css-color input {
+ padding: .15em .3em .1em;
+ border: 1px solid hsl(220 10% 78%);
+ border-radius: .25em;
+ font-family: var(--font-mono);
+
+ &:invalid {
+ background-color: hsl(0 60% 95%);
+ border-color: hsl(0 60% 80%);
+ }
+}
+
+body {
+ font: 100%/1.5 system-ui;
+ max-width: 84em;
+ margin: 1em auto;
+ padding-inline: 1em;
+
+ .gradient {
+ height: 100px;
+ display: flex;
+ > div {
+ flex: auto;
+ background: var(--step-color);
+ }
+ }
+ .color-inputs {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .oog .gradient {
+ height: 15px;
+ }
+
+ .method .info {
+ display: none;
+ }
+ .gradients:not(.flush) {
+ .method .info {
+ display: block;
+ }
+ }
+ .gamut-legend {
+ ul{
+ list-style: none;
+ display: flex;
+ gap: 3em;
+ padding: 0;
+ }
+ .color-block {
+ height: 1em;
+ width: 1em;
+ display: inline-block;
+ background-color: var(--step-color);
+ }
+ }
+ .footer {
+ margin-top: 2em;
+ text-align: center;
+ }
+}
+details.timing-info {
+ margin-top: 2em;
+ > dl {
+ display: flex;
+ }
+ .timing-result{
+ padding: 1em;
+ dd {
+ margin: 0;
+ }
+ }
+ .metric > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1em;
+ }
+
+}
\ No newline at end of file
diff --git a/apps/gamut-mapping/gradients.html b/apps/gamut-mapping/gradients.html
new file mode 100644
index 00000000..62bfd836
--- /dev/null
+++ b/apps/gamut-mapping/gradients.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Gamut Mapping Experiments - Gradients
+
+
+
+
+
+
+
+
+
+
+
+
+
{{steps.length}} Gradient Steps
+
+
+
+
+
+
+
+
+
+
+
+
Gamut indicator
+ Shows the smallest gamut that the color from the unmapped gradient fits in.
+
+ - In sRGB
+ - In p3
+ - In rec2020
+ - Out of rec2020
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/gamut-mapping/gradients.js b/apps/gamut-mapping/gradients.js
new file mode 100644
index 00000000..bbd9011d
--- /dev/null
+++ b/apps/gamut-mapping/gradients.js
@@ -0,0 +1,113 @@
+import { createApp } from "https://unpkg.com/vue@3.2.37/dist/vue.esm-browser.js";
+import Color from "../../dist/color.js";
+import Gradient from "./mapped-gradient.js";
+import TimingInfo from "./timing-info.js";
+
+globalThis.Color = Color;
+
+let app = createApp({
+ data () {
+ let params = new URLSearchParams(location.search);
+ const urlFromColor = params.get("from");
+ const urlToColor = params.get("to");
+ const from = urlFromColor || "oklch(90% .8 250)";
+ const to = urlToColor || "oklch(40% .1 20)";
+ const methods = ["none", "clip", "scale-lh", "css", "raytrace", "edge-seeker"];
+ const runResults = {};
+ methods.forEach(method => runResults[method] = []);
+ return {
+ methods: methods,
+ from: from,
+ to: to,
+ parsedFrom: this.tryParse(from),
+ parsedTo: this.tryParse(to),
+ space: "oklch",
+ maxDeltaE: 10,
+ flush: false,
+ params: params,
+ interpolationSpaces: ["oklch", "oklab", "p3", "rec2020", "lab"],
+ runResults: runResults,
+ };
+ },
+
+ computed: {
+ steps () {
+ if ( !this.parsedFrom || !this.parsedTo) {
+ return [];
+ }
+ const from = new Color(this.parsedFrom);
+ let steps = from.steps(this.parsedTo, {
+ maxDeltaE: this.maxDeltaE,
+ space: this.space,
+ });
+ return steps;
+ },
+ oogSteps () {
+ return this.steps.map(step => {
+ switch (true) {
+ case step.inGamut("srgb"):
+ return ["in srgb", "yellowgreen"];
+ case step.inGamut("p3"):
+ return ["in p3", "gold"];
+ case step.inGamut("rec2020"):
+ return ["in rec2020", "orange"];
+ default:
+ return ["out of rec2020", "red"];
+ }
+ });
+ },
+ },
+
+ methods: {
+ colorChangeFrom (event) {
+ this.parsedFrom = this.tryParse(event.detail.color) || this.parsedFrom;
+ },
+ colorChangeTo (event) {
+ this.parsedTo = this.tryParse(event.detail.color) || this.parsedFrom;
+ },
+ tryParse (input) {
+ try {
+ const color = new Color.parse(input);
+ return color;
+ }
+ catch (error) {
+ // do nothing
+ }
+ },
+ reportTime ({time, method}) {
+ this.runResults[method].push(time);
+ this.runResults = {...this.runResults};
+ },
+ },
+
+ watch: {
+ from: {
+ handler (value) {
+ this.params.set("from", value);
+ history.pushState(null, "", "?" + this.params.toString());
+ },
+ deep: true,
+ immediate: true,
+ },
+ to: {
+ handler (value) {
+ this.params.set("to", value);
+ history.pushState(null, "", "?" + this.params.toString());
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+
+ components: {
+ "mapped-gradient": Gradient,
+ "timing-info": TimingInfo,
+ },
+ compilerOptions: {
+ isCustomElement (tag) {
+ return tag === "css-color";
+ },
+ },
+}).mount(document.body);
+
+globalThis.app = app;
diff --git a/apps/gamut-mapping/mapped-gradient.js b/apps/gamut-mapping/mapped-gradient.js
new file mode 100644
index 00000000..d3ece3fc
--- /dev/null
+++ b/apps/gamut-mapping/mapped-gradient.js
@@ -0,0 +1,69 @@
+import methods from "./methods.js";
+
+export default {
+ props: {
+ method: String | Object,
+ steps: Array,
+ },
+
+ emits: ["report-time"],
+
+ data () {
+ return {
+ time: 0,
+ mappedSteps: [],
+ };
+ },
+
+ computed: {
+ name () {
+ return methods[this.method]?.label || "None";
+ },
+ },
+
+ methods: {
+ mapSteps () {
+ const start = performance.now();
+ let steps = this.steps.map(step => {
+ let mappedColor;
+ if (this.method === "none") {
+ return step;
+ }
+ if (methods[this.method].compute) {
+ mappedColor = methods[this.method].compute(step);
+ }
+ else {
+ mappedColor = step.clone().toGamut({ space: "p3", method: this.method });
+ }
+ return mappedColor;
+ });
+ this.time = Color.util.toPrecision(performance.now() - start, 4);
+ this.$emit("report-time", {time: this.time, method: this.method});
+ this.mappedSteps = steps;
+ },
+ },
+
+ watch: {
+ steps: {
+ handler () {
+ this.mapSteps();
+ },
+ immediate: true,
+ },
+ },
+
+ compilerOptions: {
+ isCustomElement (tag) {
+ return tag === "css-color";
+ },
+ },
+
+ template: `
+
+
{{ name }} {{time}}ms
+
+
+ `,
+};
diff --git a/apps/gamut-mapping/timing-info.js b/apps/gamut-mapping/timing-info.js
new file mode 100644
index 00000000..c1c19a16
--- /dev/null
+++ b/apps/gamut-mapping/timing-info.js
@@ -0,0 +1,47 @@
+import methods from "./methods.js";
+import Color from "../../dist/color.js";
+
+export default {
+ props: {
+ runResults: Object,
+ },
+
+ computed: {
+ results () {
+ const [none, ...methodsTested] = Object.keys(this.runResults);
+ return methodsTested.map(method => {
+ const data = this.runResults[method];
+ const total = data.reduce((acc, value) => acc + value);
+
+ return {
+ id: method,
+ name: methods[method].label,
+ metrics: {
+ runs: data.length,
+ min: Color.util.toPrecision(Math.min(...data), 8),
+ max: Color.util.toPrecision(Math.max(...data), 8),
+ mean: Color.util.toPrecision(total / data.length, 8),
+ },
+ };
+ }).sort((a, b) => a.metrics.mean - b.metrics.mean);
+ },
+ },
+ template: `
+ Timing info
+
+
+
- {{ method.name }}
+
-
+
+
+
- {{ metric.toUpperCase() }}
+ - {{ value }}
+
+
+
+
+
+
+ `,
+};
+