5 <title>color_canvas.html
</title>
10 * Default is a 100x100 'css' canvas-equivalent div, filled with 50% srgb red.
12 * We default to showing the settings pane when loaded without a query string.
13 This way, someone naively opens this in a browser, they can immediately see
14 all available options.
16 * The 'Publish' button updates the url, and so causes the settings pane to
19 * Clicking on the canvas toggles the settings pane for further editing.
22 <form id=e_settings
><fieldset><legend>Settings
</legend>
23 Width:
<input id=e_width type=text value=
100>
25 Height:
<input id=e_height type=text value=
100>
27 <fieldset><legend>Canvas Context
</legend>
28 Type:
<select id=e_context
>
29 <option value=css selected
>css
</option>
30 <option value=
2d
>2d
</option>
31 <option value=webgl
>webgl
</option>
34 Options:
<input id=e_options type=text value={}
>
36 Colorspace:
<input id=e_cspace type=text placeholder=srgb
>
38 WebGL Format:
<input id=e_webgl_format type=text placeholder=RGBA8
>
41 Color:
<input id=e_color type=text value='color(srgb
0 0.5 0)'
>
43 <input id=e_publish type=button value=Publish
>
44 <input type=checkbox id=e_publish_omit_defaults checked
><label for=e_publish_omit_defaults
>Omit defaults
</label>
47 <div id=e_canvas_list
><canvas></canvas></div>
53 function walk_nodes_depth_first(e
, fn
) {
54 if (fn(e
) === false) return; // Don't stop on `true`, or `undefined`!
55 for (const c
of e
.childNodes
) {
56 walk_nodes_depth_first(c
, fn
);
62 // Click the canvas to toggle the settings pane.
63 e_canvas_list
.addEventListener('click', () => {
64 // Toggle display:none to hide/unhide.
65 e_settings
.hidden
= !e_settings
.hidden
;
68 // Hide settings initially if there's a query string in the url.
69 if (window
.location
.search
.startsWith('?')) {
70 e_settings
.hidden
= true;
75 // Imply .name from .id, because `new FormData` collects based on names.
76 walk_nodes_depth_first(e_settings
, e
=> {
79 e
._default_value
= e
.value
;
85 const URL_PARAMS
= new URLSearchParams(window
.location
.search
);
86 URL_PARAMS
.forEach((v
,k
) => {
90 console
.warn(`Unrecognized setting: ${k} = ${v}`);
94 v
= decode_url_v(k
,v
);
100 globalThis
.ASSERT
= (() => {
101 function toPrettyString(arg
) {
102 if (!arg
) return ''+arg
;
105 let s
= arg
.toString();
106 const RE_TRIVIAL_LAMBDA
= /\( *\) *=> *(.*)/;
107 const m
= RE_TRIVIAL_LAMBDA
.exec(s
);
109 s
= '`' + m
[1] + '`';
113 if (arg
.constructor == Array
) {
114 return `[${[].join.call(arg, ', ')}]`;
116 return JSON
.stringify(arg
);
119 /// new AssertArg(): Construct a wrapper for args to assert functions.
120 function AssertArg(dict
) {
121 this.label
= Object
.keys(dict
)[0];
123 this.set = function(arg
) {
125 this.value
= (arg
&& arg
.call
) ? arg
.call() : arg
;
126 this.value
= toPrettyString(this.value
);
128 this.set(dict
[this.label
]);
130 this.toString = function() {
131 let ret
= `${this.label} ${toPrettyString(this.arg)}`;
133 ret
+= ` (${this.value})`;
139 const eq
= (a
,b
) => a
== b
;
140 const neq
= (a
,b
) => a
!= b
;
142 const CMP_BY_NAME
= {
147 function IS(cmp
, was
, expected
, _console
) {
148 _console
= _console
|| console
;
150 _console
.assert(was
.call
, '`was.call` not defined.');
151 was
= new AssertArg({was
});
152 expected
= new AssertArg({expected
});
154 const fn_cmp
= CMP_BY_NAME
[cmp
] || cmp
;
156 _console
.assert(fn_cmp(was
.value
, expected
.value
), `${toPrettyString(was.arg)} => ${was.value} not ${cmp} ${expected}`);
157 if (was
.value
!= expected
.value
) {
158 } else if (globalThis
.ASSERT
&& globalThis
.ASSERT
.verbose
) {
159 const maybe_cmp_str
= (cmp
== '==') ? '' : ` ${was.value} ${cmp}`;
160 _console
.log(`${toPrettyString(was.arg)} => ${maybe_cmp_str}${expected}`);
166 const MOCK_CONSOLE
= {
168 assert: function(expr
, ...args
) {
170 this._asserts
.push(args
);
173 log: function(...args
) {
181 IS('==', () => 1, 1, MOCK_CONSOLE
);
182 console
.assert(MOCK_CONSOLE
._asserts
.length
== 0, MOCK_CONSOLE
._asserts
);
183 MOCK_CONSOLE
._asserts
= [];
185 IS('==', () => 2, 2, MOCK_CONSOLE
);
186 console
.assert(MOCK_CONSOLE
._asserts
.length
== 0, MOCK_CONSOLE
._asserts
);
187 MOCK_CONSOLE
._asserts
= [];
189 IS('==', () => 5, () => 3, MOCK_CONSOLE
);
190 console
.assert(MOCK_CONSOLE
._asserts
.length
== 1, MOCK_CONSOLE
._asserts
);
191 MOCK_CONSOLE
._asserts
= [];
193 IS('==', () => [1,2], () => [1,2], MOCK_CONSOLE
);
194 console
.assert(MOCK_CONSOLE
._asserts
.length
== 0, MOCK_CONSOLE
._asserts
);
195 MOCK_CONSOLE
._asserts
= [];
200 IS('!=', () => [1,2,5], () => [1,2,3], MOCK_CONSOLE
);
201 console
.assert(MOCK_CONSOLE
._asserts
.length
== 0, MOCK_CONSOLE
._asserts
);
202 MOCK_CONSOLE
._asserts
= [];
210 ret
.EQ
= (was
,expected
) => ret
.IS('==', was
, expected
);
211 ret
.NEQ
= (was
,expected
) => ret
.IS('!=', was
, expected
);
212 ret
.EEQ
= (was
,expected
) => ret
.IS('===', was
, expected
);
213 ret
.NEEQ
= (was
,expected
) => ret
.IS('!==', was
, expected
);
214 ret
.TRUE
= was
=> ret
.EQ(was
, true);
215 ret
.FALSE
= was
=> ret
.EQ(was
, false);
216 ret
.NULL
= was
=> ret
.EEQ(was
, null);
222 function parse_css_rgb(str
) {
224 const m
= /rgba?\(([^,]+),([^,]+),([^/)]+)(?: *\/([^)]+))?\)/.exec(str
);
226 const rgba
= m
.slice(1,1+4).map((s
,i
) => {
227 if (s
=== undefined && i
== 3) {
228 s
= '1'; // Alpha defaults to 1.
231 let v
= parseFloat(s
);
232 if (s
.endsWith('%')) {
235 if (i
< 3) { // r,g,b but not a!
243 ASSERT
.EQ(() => parse_css_rgb('rgb(255,255,255)'), [1,1,1,1]);
244 ASSERT
.EQ(() => parse_css_rgb('rgba(255,255,255)'), [1,1,1,1]);
245 ASSERT
.EQ(() => parse_css_rgb('rgb(255,255,255)'), [1,1,1,1]);
246 ASSERT
.EQ(() => parse_css_rgb('rgba(255,255,255)'), [1,1,1,1]);
247 ASSERT
.EQ(() => parse_css_rgb('rgb(20,40,60)'), () => [20/255, 40/255, 60/255, 1]);
248 ASSERT
.EQ(() => parse_css_rgb('rgb(20,40,60 / 0.5)'), () => [20/255, 40/255, 60/255, 0.5]);
249 ASSERT
.EQ(() => parse_css_rgb('rgb(20,40,60 / 0)'), () => [20/255, 40/255, 60/255, 0]);
253 function parse_css_color(str
) {
254 // color ( srgb R G B /A )
255 const m
= /color\( *([^ ]+) +([^ ]+) +([^ ]+) +([^/)]+)(?:\/([^)]+))?\)/.exec(str
);
257 return ['srgb', ...parse_css_rgb(str
)];
260 const cspace
= m
[1].trim();
261 let has_extreme_colors
= false;
262 const rgba
= m
.slice(2, 2+4).map((s
,i
) => {
263 if (s
=== undefined && i
== 3) {
264 s
= '1'; // Alpha defaults to 1.
267 let v
= parseFloat(s
);
268 if (s
.endsWith('%')) {
271 if (v
< 0 || v
> 1) {
272 has_extreme_colors
= true;
276 if (has_extreme_colors
) {
277 console
.warn(`parse_css_color('${str}') has colors outside [0.0,1.0]: ${JSON.stringify(rgba)}`);
279 return [cspace
, ...rgba
];
281 ASSERT
.EQ(() => parse_css_color('rgb(255,255,255)'), ['srgb',1,1,1,1]);
282 ASSERT
.EQ(() => parse_css_color('rgb(20,40,60 / 0.5)'), () => ['srgb', 20/255, 40/255, 60/255, 0.5]);
283 ASSERT
.EQ(() => parse_css_color('color(srgb 1 0 1 /0.3)'), ['srgb',1,0,1,0.3]);
284 ASSERT
.EQ(() => parse_css_color('color(display-p3 1 0% 100%/ 30%)'), ['display-p3',1,0,1,0.3]);
289 constructor(cspace
, r
,g
,b
,a
=1) {
290 this.cspace
= cspace
;
291 this.rgba
= [this.r
, this.g
, this.b
, this.a
] = [r
,g
,b
,a
];
292 this.rgb
= this.rgba
.slice(0,3);
293 this.tuple
= [this.cspace
, ...this.rgba
];
297 return `color(${this.cspace} ${this.rgb.join(' ')} / ${this.a})`;
300 CssColor
.parse = function(str
) {
301 return new CssColor(...parse_css_color(str
));
307 STR
= 'color(display-p3 1 0 1 / 0.3)';
308 ASSERT
.EQ(() => CssColor
.parse(STR
).toString(), STR
);
310 // Test round-trip normalization
311 ASSERT
.EQ(() => CssColor
.parse('color( display-p3 1 0 1/30% )').toString(), 'color(display-p3 1 0 1 / 0.3)');
317 while (e_canvas_list
.firstChild
) {
318 e_canvas_list
.removeChild(e_canvas_list
.firstChild
);
321 const c
= make_canvas(e_color
.value
.trim());
322 c
.style
.border
= '4px solid black';
323 e_canvas_list
.appendChild(c
);
326 function fill_canvas_rect(context
/*: CanvasRenderingContext | WebGLRenderingContext*/, css_color
, rect
=null) {
327 rect
= rect
|| {left
: 0, top
: 0, w
: context
.canvas
.width
, h
: context
.canvas
.height
};
329 const is_c2d
= ('fillRect' in context
);
332 c2d
.fillStyle
= css_color
;
333 c2d
.fillRect(rect
.left
, rect
.top
, rect
.w
, rect
.h
);
337 const is_webgl
= ('drawArrays' in context
);
340 console
.assert(context
.canvas
.width
== gl
.drawingBufferWidth
, context
.canvas
.width
, '!=', gl
.drawingBufferWidth
);
341 console
.assert(context
.canvas
.height
== gl
.drawingBufferHeight
, context
.canvas
.height
, '!=', gl
.drawingBufferHeight
);
343 gl
.enable(gl
.SCISSOR_TEST
);
344 gl
.disable(gl
.DEPTH_TEST
);
345 const bottom
= rect
.top
+ rect
.h
; // in y-down c2d coords
346 gl
.scissor(rect
.left
, context
.canvas
.height
- bottom
, rect
.w
, rect
.h
);
348 const canvas_cspace
= context
.drawingBufferColorSpace
|| 'srgb';
349 if (css_color
.cspace
!= canvas_cspace
) {
350 console
.warn(`Ignoring mismatched color vs webgl canvas cspace: ${css_color.cspace} vs ${canvas_cspace}`);
352 gl
.clearColor(...css_color
.rgba
);
353 gl
.clear(gl
.COLOR_BUFFER_BIT
);
357 console
.error('Unhandled context kind:', context
);
360 window
.e_canvas
= null;
362 function make_canvas(css_color
) {
363 css_color
= CssColor
.parse(css_color
);
365 // `e_width` and e_friends are elements (by id) that we added to the raw HTML above.
366 // `e_width` is an old shorthand for `window.e_width || document.getElementById('e_width')`.
367 const W
= parseInt(e_width
.value
);
368 const H
= parseInt(e_height
.value
);
369 if (e_context
.value
== 'css') {
370 e_canvas
= document
.createElement('div');
371 e_canvas
.style
.width
= `${W}px`;
372 e_canvas
.style
.height
= `${H}px`;
373 e_canvas
.style
.backgroundColor
= css_color
;
376 e_canvas
= document
.createElement('canvas');
380 let requested_options
= JSON
.parse(e_options
.value
);
381 requested_options
.colorSpace
= e_cspace
.value
|| undefined;
383 const context
= e_canvas
.getContext(e_context
.value
, requested_options
);
384 if (requested_options
.colorSpace
) {
385 if (!context
.drawingBufferColorSpace
) {
386 console
.warn(`${context.constructor.name}.drawingBufferColorSpace not supported by browser.`);
388 context
.drawingBufferColorSpace
= requested_options
.colorSpace
;
392 if (e_webgl_format
.value
) {
393 if (!context
.drawingBufferStorage
) {
394 console
.warn(`${context.constructor.name}.drawingBufferStorage not supported by browser.`);
396 context
.drawingBufferStorage(W
, H
, context
[e_webgl_format
.value
]);
401 if (!context
.getContextAttributes
) {
402 console
.warn(`${canvas.constructor.name}.getContextAttributes not supported by browser.`);
403 actual_options
= requested_options
;
405 actual_options
= context
.getContextAttributes();
410 fill_canvas_rect(context
, css_color
);
415 e_settings
.addEventListener('change', async () => {
417 const e_updated
= document
.createElement('i');
418 e_updated
.textContent
= '(Updated!)';
419 document
.body
.appendChild(e_updated
);
420 await
new Promise(go
=> setTimeout(go
, 1000));
421 document
.body
.removeChild(e_updated
);
427 function encode_url_v(k
,v
) {
428 if (k
== 'e_color') {
429 v
= v
.replaceAll(' ', ',');
431 console
.assert(!v
.includes(' '), v
);
434 function decode_url_v(k
,v
) {
435 console
.assert(!v
.includes(' '), v
);
436 if (k
== 'e_color') {
437 v
= v
.replaceAll(',', ' ');
441 ASSERT
.EQ(() => decode_url_v('e_color', encode_url_v('e_color', 'color(srgb 1 0 0)')), 'color(srgb 1 0 0)')
443 e_publish
.addEventListener('click', () => {
444 const fd
= new FormData(e_settings
);
446 for (let [k
,v
] of fd
) {
448 if (e_publish_omit_defaults
.checked
&& v
== e
._default_value
) continue;
450 v
= encode_url_v(k
,v
);
451 settings
.push(`${k}=${v}`);
453 settings
= settings
.join('&');
455 settings
= '='; // Empty key-value pair is 'publish with default settings'
457 window
.location
.search
= '?' + settings
;