yjk: New “The YJK screen modes” article
M articles/index.php +1 -0
@@ 40,6 40,7 @@ 
     <li><a href="/articles/scrolling.php">A guide to scrolling game engines on MSX</a> <span class="map">MAP</span></li>
     <li><a href="/articles/vdp-vram-timing/vdp-timing.html">V9938 VRAM timings</a> · by the <a href="http://openmsx.org/">openMSX</a> team</li>
     <li><a href="/articles/vdp-vram-timing/vdp-timing-2.html">V9938 VRAM timings, part II</a> · by the <a href="http://openmsx.org/">openMSX</a> team</li>
+    <li><a href="/articles/yjk/">The YJK screen modes</a> <span class="map">MAP</span></li>
     </ul>
   </li>
   <li id="other">Other topics

          
A => articles/yjk/index.php +601 -0
@@ 0,0 1,601 @@ 
+<?php include $_SERVER['DOCUMENT_ROOT'].'/scripts/functions.php'; addHTTPHeader(); ?>
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
+<head>
+  <title>The YJK screen modes</title>
+  <?php addStyles(); ?>
+  <link rel="stylesheet" type="text/css" href="yjk.css" />
+  <script type="module" src="vector.js"></script>
+  <script type="module" src="visualizer.js"></script>
+  <script type="module">
+    import { Visualizer } from "./visualizer.js";
+    new Visualizer(document.querySelector('.visualizer'));
+  </script>
+</head>
+<body>
+<?php addHeader(); ?>
+
+<h1 id="colour-space">The YJK screen modes</h1>
+
+<figure class="visualizer tool">
+  <figcaption>Interactive YJK colour space visualisation</figcaption>
+  <p class="charts">
+    <canvas class="jk" width="384" height="384"></canvas>
+    <canvas class="y" width="32" height="384"></canvas>
+  </p>
+  <p class="controls">
+    <span class="yjk group">
+      <label>Y <input class="y" type="number" min="0" max="31" size="3" value="16" /></label>
+      <label>J <input class="j" type="number" min="-32" max="31" size="3" /></label>
+      <label>K <input class="k" type="number" min="-32" max="31" size="3" /></label>
+    </span>
+    <span class="rgb group">
+      <label>R <input class="r" type="number" min="0" max="31" size="3" /></label>
+      <label>G <input class="g" type="number" min="0" max="31" size="3" /></label>
+      <label>B <input class="b" type="number" min="0" max="31" size="3" /></label>
+    </span>
+  </p>
+  <p class="options">
+    <label><input class="range" id="range" type="checkbox" /> Clip range</label>
+    <label><input class="yae" type="checkbox" /> YAE mode</label>
+  </p>
+</figure>
+
+<p>An exploration of the YJK screen modes of the MSX2+.</p>
+
+<ul>
+  <li><a href="#introduction">Introduction</a></li>
+  <li><a href="#selecting">Selecting the YJK modes</a></li>
+  <li><a href="#encoding">The encoding of YJK</a></li>
+  <li><a href="#conversion">Conversion between YJK and RGB</a></li>
+  <li><a href="#drawing">Drawing techniques</a></li>
+  <li><a href="#comparison">Comparison to Y′UV</a></li>
+</ul>
+
+
+<h2 id="introduction">Introduction</h2>
+
+<p>
+  The Yamaha V9958 <abbr title="Video Display Processor">VDP</abbr> used in the
+  MSX2+ and MSX turbo R adds two high colour display modes which use a different
+  colour encoding than traditional RGB, called YJK and YJK+YAE. These modes
+  expand the colour count to 19268 and 12499 + 16 respectively, at the cost of
+  colour attribute clash between groups of 4×1 pixels.
+</p>
+
+<p>
+  The YJK mode is MSX-BASIC’s SCREEN 12. It can show up to 19268 colours, and is
+  particularly suited for photographic images because these are generally fairly
+  unaffected by colour attribute clash. See
+  <a href="http://tomseditor.com/gallery/browse?platform=msx&amp;format=screen12">art gallery</a>.
+</p>
+
+<p>
+  The YJK+YAE mode is MSX-BASIC’s SCREEN 10 and 11. It can show up to 12499 YJK
+  colours, and 16 palette colours (out of 512). Since the palette colours are set
+  per-pixel they can be used to hide colour bleeding, while the YJK colours can
+  be used to make the picture more colourful. Due to this YJK+YAE is more versatile
+  in situations when you also want to display a user interface, like in a game. See
+  <a href="http://tomseditor.com/gallery/browse?platform=msx&amp;format=screen10">art gallery</a>.
+</p>
+
+<p>
+  An additional benefit of these new screen modes have compared to SCREEN 8 is
+  that their sprites make use of the palette, rather than a limited set of
+  predefined colours.
+</p>
+
+
+
+<h2 id="selecting">Selecting the YJK modes</h2>
+
+<p>
+  The MSX2+ BIOS <a href="http://map.grauw.nl/resources/msxbios.php#CHGMOD">CHGMOD</a>
+  routine can be called with the MSX-BASIC SCREEN number in register A.
+</p>
+
+<p>
+  On the VDP the YJK mode is selected by following the procedure to select
+  SCREEN 8 (G7), and then setting the YJK bit of register 25. To select the
+  YJK+YAE mode, additionally set the YAE bit.
+</p>
+
+<figure>
+  <table>
+    <caption>VDP register 25</caption>
+    <tr><th></th><th>7</th><th>6</th><th>5</th><th>4</th><th>3</th><th>2</th><th>1</th><th>0</th></tr>
+    <tr><th>r#25</th><td>0</td><td>CMD</td><td>VDS</td><td>YAE</td><td>YJK</td><td>WTE</td><td>MSK</td><td>SP2</td></tr>
+  </table>
+</figure>
+
+<figure>
+  <table>
+    <caption>Combination of YJK and YAE data</caption>
+    <tr><th>YJK</th><th>YAE</th><th>VRAM data</th></tr>
+    <tr><td rowspan="2">0</td><td>0</td><td rowspan="2">Via the conventional colour palette</td></tr>
+    <tr><td>1</td></tr>
+    <tr><td rowspan="2">1</td><td>0</td><td>A=0: Via the RGB → YJK conversion table</td></tr>
+    <tr><td>1</td><td>A=1: Via the colour palette</td></tr>
+  </table>
+</figure>
+
+<p>
+  Since a number of MSX2 computers exist that do have a V9958 but no BIOS
+  support, as well modded MSX2 computers with their VDP replaced, consider using
+  <a href="http://map.grauw.nl/resources/msxbios.php#CHGMOD">CHGMOD</a> to
+  switch to SCREEN 8 and then setting the YJK and YAE bits in r#25
+  manually. The other bits can be left at 0.
+</p>
+
+<p>
+  In MSX-BASIC, SCREEN 12 and 11 have no special support for drawing in YJK
+  other than using 8-bit colour operations. However SCREEN 10 is special,
+  it gives a palette-colour view of YJK+YAE and allows you to draw on it like
+  in SCREEN 5. Switching between SCREEN 10 and 11 will not clear the screen.
+</p>
+
+
+<h2 id="encoding">The encoding of YJK</h2>
+
+<p>
+  YJK breaks down into three components. The Y component specifies brightness (luma)
+  and a little bit of blue. The J and K components contain most of the colour
+  information (chroma), expressed as a difference between Y and red and green,
+  respectively. The concept behind this encoding is that the human eye is more
+  sensitive to brightness than colour, and the colour resolution can therefore be
+  reduced with relatively limited impact on the perceived result.
+</p>
+
+<p>
+  Use the visualisation widget above to get a feel of the YJK colour space.
+  You can interact with this widget to view the various colour ramps available.
+  The horizontal axis is the J value, the vertical axis is K. The column on the
+  right controls the Y amount. All colours in the Y column can be used in the
+  same four-pixel group. As you increase Y the colour becomes brighter, but
+  notice that it becomes slightly more blue as well. This can be seen well in
+  the grayish hue when both J and K are zero.
+</p>
+
+<p>
+  In YJK mode the VDP stores the J and K components in 6 bits as a two’s complement
+  signed value (-32 to 31). These are shared between 4 adjacent pixels. The Y component
+  is a 5-bit unsigned value (0 to 31). This one can be specified per-pixel.
+</p>
+
+<figure>
+  <table>
+    <caption>YJK (SCREEN 12)</caption>
+    <tr><th></th><th>C7</th><th>C6</th><th>C5</th><th>C4</th><th>C3</th><th>C2</th><th>C1</th><th>C0</th></tr>
+    <tr><td>1 dot</td><td colspan="5">Y<sub>1</sub></td><td colspan="3">K<sub>low</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="5">Y<sub>2</sub></td><td colspan="3">K<sub>high</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="5">Y<sub>3</sub></td><td colspan="3">J<sub>low</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="5">Y<sub>4</sub></td><td colspan="3">J<sub>high</sub></td></tr>
+  </table>
+</figure>
+
+<p>
+  In YJK+YAE mode the VDP still stores the J and K components in 6 bits as a
+  two’s complement signed value (-32 to 31). However the Y component can now
+  only specify even values (0, 2, 4 .. 30), as its least significant bit is
+  used for the attribute bit A. If the A bit is set, the top four bits of
+  the Y component specify a palette colour to use instead.
+</p>
+
+<figure>
+  <table>
+    <caption>YJK+YAE (SCREEN 10-11)</caption>
+    <tr><th></th><th>C7</th><th>C6</th><th>C5</th><th>C4</th><th>C3</th><th>C2</th><th>C1</th><th>C0</th></tr>
+    <tr><td>1 dot</td><td colspan="4">Y<sub>1</sub></td><td>A</td><td colspan="3">K<sub>low</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="4">Y<sub>2</sub></td><td>A</td><td colspan="3">K<sub>high</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="4">Y<sub>3</sub></td><td>A</td><td colspan="3">J<sub>low</sub></td></tr>
+    <tr><td>1 dot</td><td colspan="4">Y<sub>4</sub></td><td>A</td><td colspan="3">J<sub>high</sub></td></tr>
+  </table>
+</figure>
+
+<p>
+  The palette is still specified as 9 bits, with 3 bits per component. However
+  the V9958’s DAC is 15-bit, so there must be some kind of mapping in place.
+  Indeed, the 3-bit RGB palette component values map to 5 bits as follows:
+</p>
+
+<figure>
+  <table>
+    <caption>3-bit to 5-bit conversion</caption>
+    <tr><th>V9938</th><th>V9958</th></tr>
+    <tr><td>0</td><td>0</td></tr>
+    <tr><td>1</td><td>4</td></tr>
+    <tr><td>2</td><td>9</td></tr>
+    <tr><td>3</td><td>13</td></tr>
+    <tr><td>4</td><td>18</td></tr>
+    <tr><td>5</td><td>22</td></tr>
+    <tr><td>6</td><td>27</td></tr>
+    <tr><td>7</td><td>31</td></tr>
+  </table>
+</figure>
+
+<figure>
+  <figcaption>3-bit to 5-bit conversion</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow>
+        <msub><mi>c</mi><mi>out</mi></msub>
+        <mo>=</mo>
+        <mo>⌊</mo>
+        <mn>4.5</mn>
+        <mo>&#x2062;</mo>
+        <msub><mi>c</mi><mi>in</mi></msub>
+        <mo>⌋</mo>
+      </mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow>
+        <msub><mi>c</mi><mi>out</mi></msub>
+        <mo>=</mo>
+        <msub><mi>c</mi><mi>in</mi></msub>
+        <mo>&lt;&lt;</mo>
+        <mn>2</mn>
+        <mo>OR</mo>
+        <msub><mi>c</mi><mi>in</mi></msub>
+        <mo>>></mo>
+        <mn>1</mn>
+      </mrow>
+    </math>
+  </p>
+</figure>
+
+<p>This applies to all screen modes, by the way.</p>
+
+
+<h2 id="conversion">Conversion between YJK and RGB</h2>
+
+<p>
+  The Y, J and K values specify 17 bits of information in total. The V9958
+  converts these to 15-bit RGB with 5 bits per colour component. The
+  <a href="/resources/video/yamaha_v9958.pdf">V9958 manual</a> describes the
+  following conversion formulas for YJK. The formulas for YJK+YAE are the same,
+  but the least significant bit of Y is always zero so all its values are even.
+</p>
+
+<figure>
+  <figcaption>YJK to RGB</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>r</mi> <mo>=</mo> <mi>y</mi> <mo>+</mo> <mi>j</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>g</mi> <mo>=</mo> <mi>y</mi> <mo>+</mo> <mi>k</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>b</mi> <mo>=</mo>
+        <mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>5</mn><mo>&#x2062;</mo><mi>y</mi> <mo>-</mo> <mn>2</mn><mo>&#x2062;</mo><mi>j</mi> <mo>-</mo> <mi>k</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>4</mn></mrow>
+        </mfrac>
+      </mrow>
+    </math>
+  </p>
+</figure>
+
+<figure>
+  <figcaption>RGB to YJK</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>y</mi> <mo>=</mo>
+        <mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>4</mn><mo>&#x2062;</mo><mi>b</mi> <mo>+</mo> <mn>2</mn><mo>&#x2062;</mo><mi>r</mi> <mo>+</mo> <mi>g</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>8</mn></mrow>
+        </mfrac>
+      </mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>j</mi> <mo>=</mo> <mi>r</mi> <mo>-</mo> <mi>y</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>k</mi> <mo>=</mo> <mi>g</mi> <mo>-</mo> <mi>y</mi></mrow>
+    </math>
+  </p>
+</figure>
+
+<p>
+  Indeed those are fine formulas and perfect mathematical inverses of each other.
+  However they do not take into account that the VDP both rounds and clips the
+  result of the YJK to RGB conversion.
+</p>
+
+
+<h3 id="rounding">Rounding</h3>
+
+<p>
+  Let’s consider the rounding first. The y, j, k and r, g, b values are all
+  integers, so when the blue value is calculated, it is rounded down to the
+  nearest integer value (“floor”). To compensate for this in the RGB to YJK
+  formula, you need to round up (“ceil”) the value resulting from the y.
+</p>
+
+<figure>
+  <figcaption>YJK to RGB (rounded)</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>r</mi> <mo>=</mo> <mi>y</mi> <mo>+</mo> <mi>j</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>g</mi> <mo>=</mo> <mi>y</mi> <mo>+</mo> <mi>k</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>b</mi> <mo>=</mo> <mo>⌊</mo>
+        <mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>5</mn><mo>&#x2062;</mo><mi>y</mi> <mo>-</mo> <mn>2</mn><mo>&#x2062;</mo><mi>j</mi> <mo>-</mo> <mi>k</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>4</mn></mrow>
+        </mfrac> <mo>⌋</mo>
+      </mrow>
+    </math>
+  </p>
+</figure>
+
+<figure>
+  <figcaption>RGB to YJK (rounded)</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>y</mi> <mo>=</mo>
+        <mo>⌈</mo><mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>4</mn><mo>&#x2062;</mo><mi>b</mi> <mo>+</mo> <mn>2</mn><mo>&#x2062;</mo><mi>r</mi> <mo>+</mo> <mi>g</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>8</mn></mrow>
+        </mfrac><mo>⌉</mo>
+      </mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>j</mi> <mo>=</mo> <mi>r</mi> <mo>-</mo> <mi>y</mi></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>k</mi> <mo>=</mo> <mi>g</mi> <mo>-</mo> <mi>y</mi></mrow>
+    </math>
+  </p>
+</figure>
+
+<figure>
+  <figcaption>RGB to YJK+YAE (rounded)</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>y</mi> <mo>=</mo>
+        <mn>2</mn><mo>&#x2062;</mo><mo>⌈</mo><mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>4</mn><mo>&#x2062;</mo><mi>b</mi> <mo>+</mo> <mn>2</mn><mo>&#x2062;</mo><mi>r</mi> <mo>+</mo> <mi>g</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>16</mn></mrow>
+        </mfrac><mo>⌉</mo>
+      </mrow>
+    </math>
+  </p>
+</figure>
+
+<p>
+  Using these formula you can express exactly half of what 15-bit RGB allows;
+  16384 out of 32768 colours (50%), and 8192 for YJK+YAE (25%). Each
+  representable RGB value maps to one unique YJK value and vice versa. If you
+  want to keep things simple, stop here and stick to these 16384 colours.
+</p>
+
+
+<h3 id="clipping">Clipping</h3>
+
+<p>
+  As we know though YJK mode can generate 19268 colours. Where did the remaining
+  2884 colours go? YJK’s 17 bits allow for 131072 different values, but most of
+  these fall outside the range of 15-bit RGB. Values that are out of range are
+  clipped to [0, 31].
+</p>
+
+<figure>
+  <figcaption>YJK to RGB (clipped)</figcaption>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>r</mi> <mo>=</mo>
+        <mi>clamp</mi><mo>&#x2061;</mo><mrow><mo>(</mo><mrow><mi>y</mi> <mo>+</mo> <mi>j</mi><mo>,</mo>
+        <mn>0</mn><mo>,</mo> <mn>31</mn></mrow><mo>)</mo></mrow></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>g</mi> <mo>=</mo>
+        <mi>clamp</mi><mo>&#x2061;</mo><mrow><mo>(</mo><mrow><mi>y</mi> <mo>+</mo> <mi>k</mi><mo>,</mo>
+        <mn>0</mn><mo>,</mo> <mn>31</mn></mrow><mo>)</mo></mrow></mrow>
+    </math>
+  </p>
+  <p>
+    <math xmlns="http://www.w3.org/1998/Math/MathML">
+      <mrow><mi>b</mi> <mo>=</mo>
+        <mi>clamp</mi><mo>&#x2061;</mo><mrow><mo>(</mo><mrow><mo>⌊</mo>
+        <mfrac>
+          <mrow><mphantom mathsize="0"><mo>(</mo></mphantom><mn>5</mn><mo>&#x2062;</mo><mi>y</mi> <mo>-</mo> <mn>2</mn><mo>&#x2062;</mo><mi>j</mi> <mo>-</mo> <mi>k</mi><mphantom mathsize="0"><mo>)</mo></mphantom></mrow>
+          <mrow><mphantom mathsize="0"><mo>/</mo></mphantom> <mn>4</mn></mrow>
+        </mfrac> <mo>⌋</mo><mo>,</mo>
+        <mn>0</mn><mo>,</mo> <mn>31</mn></mrow><mo>)</mo></mrow>
+      </mrow>
+    </math>
+  </p>
+</figure>
+
+<p>
+  As a side effect of this clipping, 2884 colours which could not be represented
+  by the previous YJK formulas become available. For example, take the colour
+  (0, 24, 31). Without clipping this could not be expressed, since applying the
+  previous RGB to YJK to RGB formula will yield (0, 24, 32). Due to the clipping
+  of the blue component though, this colour is available to us after all.
+</p>
+
+<p>
+  If we calculate the number of colours affected by clipping, those which have
+  one component of either 0 or 31, there are 32<sup>3</sup> - 30<sup>3</sup> = 5768
+  of them. Half of them are already covered by unclipped colours, 5768 / 2 = 2884,
+  meaning that all of the colours where any RGB component is 0 or 31 can be
+  represented. Some of these have only one YJK representation, others up to 3463.
+</p>
+
+<p>
+  You may notice no RGB to YJK formula is specified. This is because although
+  colours whose components all fall within the [1, 30] range have a unique YJK
+  representation which can be determined with the earlier formula, colours with
+  one or more components of either 0 or 31 have multiple solutions.
+</p>
+
+<p>
+  In order to deal with this, consider that an RGB value is a point in a
+  3-dimensional (colour) space. For each potentially clipped component (0 or 31)
+  a ray in the clip direction extends from the point, where two rays form a
+  bounded plane, and three rays a volume. This colour space can be transformed
+  to YJK with a matrix, and all YJK values overlapping this point, ray, plane
+  or volume represent the same RGB colour.
+</p>
+
+<p>
+  More on this in part 2, which will be a deep dive into the topic of image conversion.
+</p>
+
+
+<h2 id="drawing">Drawing techniques</h2>
+
+<p>
+  The YJK modes can be very nice for pixel art because of the wider range of colours
+  that you an express with 15-bit RGB. Ever wanted that particular pastel colour
+  that the V9938’s 9-bit RGB palette could not express? Or a smoother gradient?
+  The V9958 can do in YJK. Additionally the high colour count allows you to
+  create very colourful artwork.
+</p>
+
+<p>
+  However on the flipside the colour restrictions of YJK make it a difficult mode
+  to work with. Below you will find some tips on how to work with the constraints
+  of the YJK modes.
+</p>
+
+<p>
+  For pixel art I recommend to use the YJK+YAE mode. The availability of palette
+  colours greatly increases your flexibility to work around the colour clash
+  restrictions. The YJK mode is more suited for photographic material with
+  smoother gradients and lower contrast.
+</p>
+
+<p>
+  The simplest technique is to approach the art like you would a SCREEN 5 image.
+  Draw the majority of the image using the 16 palette colours. Consider it a
+  layer on top of the YJK layer. Then “punch holes” through this layer by
+  introducing YJK colours in select places, adhering to the 4×1 pixel group
+  restrictions. The easiest way to do this is by simply using only a single
+  colour per pixel group. This already allows you to introduce many more colour
+  details without needing extra palette colours for them, while avoiding the
+  complexities of YJK.
+</p>
+
+<p>
+  If you want to use multiple YJK colours within those 4 pixels, you need to pay
+  attention to the colours available in the specific J, K combination for that
+  quad. This requires a bit of planning, you’ll want to start by establishing a
+  few useful colour ramps to reuse them througout the art piece. Choose colours
+  from an Y ramp you like, for example using the tool at the top of this page,
+  and then note them in a little scratch area for easy colour picking. Then
+  whenever you need to draw e.g. some grassy bits, you can pick from the green
+  ramp you’ve noted, while being mindful of the 4×1 grid.
+</p>
+
+<p>
+  For a more cartoony drawing style in YJK mode, you could draw the artwork in
+  grayscale, and then paint the colour on a separate colour modifier layer with
+  a blurry brush, so that there are no hard colour transitions. This will convert
+  to YJK well.
+</p>
+
+<p>
+  In the end to which degree you want to apply these techniques depends on how much
+  time you want to invest. Please share your experiences and tips and tricks in the
+  <a href="https://www.msx.org/forum/msx-talk/development/creating-pixel-art">msx.org
+  pixel art thread</a>!
+</p>
+
+
+<h2 id="comparison">Comparison to Y′UV</h2>
+
+<p>
+  The YJK colour space is similar to the more common <a href="https://en.wikipedia.org/wiki/YUV">Y′UV</a>
+  colour space, with 4×1 chroma subsampling like the Y′UV411 encoding. However
+  they differ in the weights applied to the different colour components. Where
+  Y′UV assigns weights to make the Y component express luma (brightness), YJK
+  has swapped the weights for green and blue, meaning that Y does not only affect
+  luma but also the amount of blue.
+</p>
+
+<p>
+  This trait can be a bit unfortunate, as in natural scenes the amount of blue
+  decreases under (sun)light, and increases in shade. Y′UV would work better
+  in those situations. YJK is perhaps better suited for metallic, fluorescent
+  and pastel colours.
+</p>
+
+<p>
+  In an interesting article <a href="http://rs.gr8bit.ru/Documentation/Issues-on-YJK-colour-model-implemented-in-Yamaha-V9958-VDP-chip.pdf">“Issues
+  on YJK colour model implemented in Yamaha V9958 VDP chip”</a>, Ricardo Cancho
+  Niemietz elaborates why this is not ideal from the perspective of the
+  luminance-chrominance colour model, and suggests that it must be an unintentional
+  mistake of the Yamaha engineers. Assuming though that Yamaha’s engineers were
+  smart people, and wouldn’t make a colour model inspired by YUV but deviate in
+  such a detail without reason, let’s try to find that reason.
+</p>
+
+<p>
+  In YJK’s 4-byte encoding there are six bits for J and K, but only five for Y.
+  Due to this it can only represent half of the RGB colours, 2<sup>14</sup>
+  instead of the 2<sup>15</sup> that 15-bit RGB can produce. Ideally Y would have
+  6 bits as well to get the full range, but this doesn’t fit in the byte encoding
+  (and certainly not in YJK+YAE). So which RGB colour component’s resolution do
+  we reduce by one bit?
+</p>
+
+<p>
+  The most logical answer here is blue, just like in SCREEN 8. Because our eyes
+  are the least perceptive of blue, we won’t notice it as much. And indeed in YJK
+  blue is effectively only 4-bit, in YJK+YAE effectively 3-bit. Had Yamaha
+  gone for YUV, green would have gotten the least resolution, while it is the
+  most visible colour, causing more banding in it. From this perspective, YJK
+  spends its bits more effectively on the colours our eye perceive best.
+</p>
+
+<p>
+  A final note on this; the Yamaha V9990 VDP has both an YJK and an YUV mode,
+  so on that VDP you can use the mode of your choosing. The YUV mode works
+  identical to YJK on the V9958 but with the weights for the G and B components
+  swapped. The weights do not perfectly match the real Y’UV but it’s pretty close.
+</p>
+
+<p>Relevant links:</p>
+
+<ul>
+  <li><a href="/resources/video/yamaha_v9958.pdf">Yamaha V9958 application manual</a></li>
+  <li><a href="https://www.msx.org/forum/msx-talk/software/enhanced-decoding-yjk-images">msx.org forum: Enhanced decoding of YJK images</a></li>
+  <li><a href="https://www.msx.org/forum/msx-talk/development/creating-pixel-art">msx.org forum: Creating pixel art</a></li>
+  <li><a href="http://rs.gr8bit.ru/Documentation/Issues-on-YJK-colour-model-implemented-in-Yamaha-V9958-VDP-chip.pdf">Issues on YJK colour model implemented in Yamaha V9958 VDP chip</a> by Ricardo Cancho Niemietz.</li>
+  <li><a href="http://www.msx-plaza.eu/home.php?page=mccm/mccm72/schermen_eng">The MSX2+ Screens</a> by Alex Wulms (<a href="https://web.archive.org/web/20181001220537/http://www.msx-plaza.eu/home.php?page=mccm/mccm72/schermen_eng">mirror</a>), published in MCCM 72 (<a href="http://www.msxarchive.nl/pub/msx/mirrors/hanso/hwdoityourself/msxplus.pdf">original Dutch</a>).</li>
+  <li><a href="http://tomseditor.com/gallery/browse?platform=msx&amp;format=screen10">Screen 10-11 art gallery</a> on Retro Gallery</li>
+  <li><a href="http://tomseditor.com/gallery/browse?platform=msx&amp;format=screen12">Screen 12 art gallery</a> on Retro Gallery</li>
+</ul>
+
+
+<p class="signed">Grauw</p>
+
+<?php addFooter(); ?>
+</body>
+</html>

          
A => articles/yjk/matrix.js +126 -0
@@ 0,0 1,126 @@ 
+import { Vector4 } from "./vector.js";
+
+export class Matrix4x4 {
+	constructor(row0, row1, row2, row3) {
+		this.row0 = row0;
+		this.row1 = row1;
+		this.row2 = row2;
+		this.row3 = row3;
+	}
+
+	static identity() {
+		return Matrix4x4.scale(Vector4.scalar(1));
+	}
+
+	static scale(vector) {
+		return new Matrix4x4(
+			new Vector4(vector.x, 0, 0, 0),
+			new Vector4(0, vector.y, 0, 0),
+			new Vector4(0, 0, vector.z, 0),
+			new Vector4(0, 0, 0, vector.w)
+		);
+	}
+
+	static translate(vector) {
+		return new Matrix4x4(
+			new Vector4(1, 0, 0, vector.x),
+			new Vector4(0, 1, 0, vector.y),
+			new Vector4(0, 0, 1, vector.z),
+			new Vector4(0, 0, 0, vector.w)
+		);
+	}
+
+	static rotateX(angle) {
+		return new Matrix4x4(
+			new Vector4(1, 0, 0, 0),
+			new Vector4(0, Math.cos(angle), -Math.sin(angle), 0),
+			new Vector4(0, Math.sin(angle), Math.cos(angle), 0),
+			new Vector4(0, 0, 0, 1)
+		);
+	}
+
+	static rotateY(angle) {
+		return new Matrix4x4(
+			new Vector4(Math.cos(angle), 0, -Math.sin(angle), 0),
+			new Vector4(0, 1, 0, 0),
+			new Vector4(Math.sin(angle), 0, Math.cos(angle), 0),
+			new Vector4(0, 0, 0, 1)
+		);
+	}
+
+	static rotateZ(angle) {
+		return new Matrix4x4(
+			new Vector4(Math.cos(angle), -Math.sin(angle), 0, 0),
+			new Vector4(Math.sin(angle), Math.cos(angle), 0, 0),
+			new Vector4(0, 0, 1, 0),
+			new Vector4(0, 0, 0, 1)
+		);
+	}
+
+	static rotateXYZ(vector) {
+		vector = vector.project(2 * Math.PI);
+		return Matrix4x4.rotateX(vector.x).multiply(Matrix4x4.rotateY(vector.y)).multiply(Matrix4x4.rotateZ(vector.z));
+	}
+
+	static orthogonal(width, height, depth, near) {
+		const far = near + depth;
+		return new Matrix4x4(
+			new Vector4(2 / width, 0, 0, 0),
+			new Vector4(0, 2 / height, 0, 0),
+			new Vector4(0, 0, -2 / depth, -(far + near) / depth),
+			new Vector4(0, 0, 0, 1)
+		);
+	}
+
+	static perspective(width, height, depth, near) {
+		const far = near + depth;
+		return new Matrix4x4(
+			new Vector4(2 * near / width, 0, 0, 0),
+			new Vector4(0, 2 * near / height, 0, 0),
+			new Vector4(0, 0, -(far + near) / depth, -2 * far * near / depth),
+			new Vector4(0, 0, -1, 0)
+		);
+	}
+
+	multiplyVector(vector) {
+		return new Vector4(this.row0.dot(vector), this.row1.dot(vector), this.row2.dot(vector), this.row3.dot(vector));
+	}
+
+	multiply(matrix) {
+		const transposed = matrix.transpose();
+		return new Matrix4x4(
+			transposed.multiplyVector(this.row0),
+			transposed.multiplyVector(this.row1),
+			transposed.multiplyVector(this.row2),
+			transposed.multiplyVector(this.row3)
+		);
+	}
+
+	transpose() {
+		return new Matrix4x4(
+			new Vector4(this.row0.x, this.row1.x, this.row2.x, this.row3.x),
+			new Vector4(this.row0.y, this.row1.y, this.row2.y, this.row3.y),
+			new Vector4(this.row0.z, this.row1.z, this.row2.z, this.row3.z),
+			new Vector4(this.row0.w, this.row1.w, this.row2.w, this.row3.w)
+		);
+	}
+
+	project(scalar) {
+		const factor = Vector4.scalar(scalar / this.row3.w);
+		return new Matrix4x4(this.row0.multiply(factor), this.row1.multiply(factor), this.row2.multiply(factor), this.row3.multiply(factor));
+	}
+
+	toFloat32Array() {
+		const array = new Float32Array(16);
+		const matrix = this.transpose();
+		matrix.row0.copyToArray(array, 0);
+		matrix.row1.copyToArray(array, 4);
+		matrix.row2.copyToArray(array, 8);
+		matrix.row3.copyToArray(array, 12);
+		return array;
+	}
+
+	toString() {
+		return `({$this.row0}, {$this.row1}, {$this.row2}, {$this.row3})`;
+	}
+}

          
A => articles/yjk/vector.js +165 -0
@@ 0,0 1,165 @@ 
+export function clamp(value, min, max) {
+	return Math.min(Math.max(value, min), max);
+}
+
+export class Vector4 {
+	constructor(x, y, z, w) {
+		this.x = x;
+		this.y = y;
+		this.z = z;
+		this.w = w;
+	}
+
+	static scalar(value) {
+		return new Vector4(value, value, value, value);
+	}
+
+	transform(matrix) {
+		return matrix.multiplyVector(this);
+	}
+
+	add(other) {
+		return new Vector4(this.x + other.x, this.y + other.y, this.z + other.z, this.w + other.w);
+	}
+
+	addw(other) {
+		return new Vector4(this.x * other.w + other.x, this.y * other.w + other.y, this.z * other.w + other.z, this.w * other.w);
+	}
+
+	subtract(other) {
+		return new Vector4(this.x - other.x, this.y - other.y, this.z - other.z, this.w - other.w);
+	}
+
+	multiply(other) {
+		return new Vector4(this.x * other.x, this.y * other.y, this.z * other.z, this.w * other.w);
+	}
+
+	average(other) {
+		return new Vector4(
+			this.x * other.w + other.x * this.w,
+			this.y * other.w + other.y * this.w,
+			this.z * other.w + other.z * this.w,
+			this.w * other.w + other.w * this.w
+		);
+	}
+
+	dot(other) {
+		return this.x * other.x + this.y * other.y + this.z * other.z + this.w * other.w;
+	}
+
+	cross(other) {
+		if (this.w || other.w)
+			throw new Error("Cross product must use a 3D vector.");
+		return new Vector4(this.y * other.z - this.z * other.y, this.z * other.x - this.x * other.z, this.x * other.y - this.y * other.x, 0);
+	}
+
+	magnitude() {
+		return Math.sqrt(this.magnitudeSquared());
+	}
+
+	magnitudeSquared() {
+		return this.dot(this);
+	}
+
+	normalize() {
+		return this.multiply(Vector4.scalar(1 / this.magnitude()));
+	}
+
+	isZero(threshold) {
+		return this.magnitudeSquared() <= (threshold || 0);
+	}
+
+	power(exponent) {
+		return new Vector4(Math.pow(this.x, exponent.x), Math.pow(this.y, exponent.y), Math.pow(this.z, exponent.z), Math.pow(this.w, exponent.w))
+	}
+
+	project(scale) {
+		return new Vector4(this.x * scale / this.w, this.y * scale / this.w, this.z * scale / this.w, scale);
+	}
+
+	min(other) {
+		return new Vector4(Math.min(this.x, other.x), Math.min(this.y, other.y), Math.min(this.z, other.z), Math.min(this.w, other.w))
+	}
+
+	max(other) {
+		return new Vector4(Math.max(this.x, other.x), Math.max(this.y, other.y), Math.max(this.z, other.z), Math.max(this.w, other.w))
+	}
+
+	clamp(min, max) {
+		return min.max(max.min(this));
+	}
+
+	saturate() {
+		return new Vector4(clamp(this.x, 0, this.w), clamp(this.y, 0, this.w), clamp(this.z, 0, this.w), this.w);
+	}
+
+	floor() {
+		return new Vector4(Math.floor(this.x), Math.floor(this.y), Math.floor(this.z), Math.floor(this.w));
+	}
+
+	ceil() {
+		return new Vector4(Math.ceil(this.x), Math.ceil(this.y), Math.ceil(this.z), Math.ceil(this.w));
+	}
+
+	round() {
+		return new Vector4(Math.round(this.x), Math.round(this.y), Math.round(this.z), Math.round(this.w));
+	}
+
+	equals(other) {
+		return this.x * other.w == other.x * this.w && this.y * other.w == other.y * this.w && this.z * other.w == other.z * this.w;
+	}
+
+	almostEquals(other) {
+		return Math.abs(this.x * other.w - other.x * this.w) < 0.01 &&
+			Math.abs(this.y * other.w - other.y * this.w) < 0.01 &&
+			Math.abs(this.z * other.w - other.z * this.w) < 0.01;
+	}
+
+	isInRange() {
+		return this.x >= 0 && this.x <= this.w && this.y >= 0 && this.y <= this.w && this.z >= 0 && this.z <= this.w;
+	}
+
+	isInInnerRange() {
+		return this.x > 0 && this.x < this.w && this.y > 0 && this.y < this.w && this.z > 0 && this.z < this.w;
+	}
+
+	toRGB() {
+		return new Vector4(this.x + this.y, this.x + this.z, Math.floor(1.25 * this.x + -0.5 * this.y + -0.25 * this.z), this.w);
+	}
+
+	toYJK() {
+		const y = Math.ceil(0.25 * this.x + 0.125 * this.y + 0.5 * this.z);
+		return new Vector4(y, this.x - y, this.y - y, this.w);
+	}
+
+	toYJKYAE() {
+		const y = 2 * Math.ceil((0.25 * this.x + 0.125 * this.y + 0.5 * this.z) / 2);
+		return new Vector4(y, this.x - y, this.y - y, this.w);
+	}
+
+	toString() {
+		return `(${this.x}, ${this.y}, ${this.z}, ${this.w})`;
+	}
+
+	toFloat32Array() {
+		return Vector4.toFloat32Array(this);
+	}
+
+	static toFloat32Array(...vectors) {
+		const array = new Float32Array(vectors.length * 4);
+		Vector4.copyToArray(array, ...vectors);
+		return array;
+	}
+
+	static copyToArray(array, ...vectors) {
+		for (let i = 0; i < vectors.length; i++)
+			vectors[i].copyToArray(array, i * 4);
+	}
+
+	copyToArray(array, offset) {
+		array[offset + 0] = this.x;
+		array[offset + 1] = this.y;
+		array[offset + 2] = this.z;
+		array[offset + 3] = this.w;
+	}
+}

          
A => articles/yjk/visualizer.js +207 -0
@@ 0,0 1,207 @@ 
+import { Vector4, clamp } from "./vector.js";
+
+export class Visualizer {
+	constructor(container) {
+		this.jkCanvas = container.querySelector("canvas.jk");
+		this.yCanvas = container.querySelector("canvas.y");
+		this.yInput = container.querySelector("input.y");
+		this.jInput = container.querySelector("input.j");
+		this.kInput = container.querySelector("input.k");
+		this.rInput = container.querySelector("input.r");
+		this.gInput = container.querySelector("input.g");
+		this.bInput = container.querySelector("input.b");
+		this.rangeCheckbox = container.querySelector("input.range");
+		this.yaeCheckbox = container.querySelector("input.yae");
+		this.editingInput = null;
+		this.yjk = new Vector4(16, 0, 0, 31);
+		this.gamma = 1.1;
+		this.clipRange = this.rangeCheckbox.checked;
+		this.yStep = this.yaeCheckbox.checked ? 2 : 1;
+
+		this.setYJK(this.getYJKInput());
+		this.update();
+
+		addSlideListener(this.jkCanvas, this.onJKCanvasClick.bind(this));
+		addSlideListener(this.yCanvas, this.onYCanvasClick.bind(this));
+		addArrowKeyListener(this.jkCanvas, this.onJKCanvasArrowKey.bind(this));
+		addArrowKeyListener(this.yCanvas, this.onYCanvasArrowKey.bind(this));
+		addNumberInputListener(this.yInput, this.onYJKInputChange.bind(this));
+		addNumberInputListener(this.jInput, this.onYJKInputChange.bind(this));
+		addNumberInputListener(this.kInput, this.onYJKInputChange.bind(this));
+		addNumberInputListener(this.rInput, this.onRGBInputChange.bind(this));
+		addNumberInputListener(this.gInput, this.onRGBInputChange.bind(this));
+		addNumberInputListener(this.bInput, this.onRGBInputChange.bind(this));
+		this.rangeCheckbox.addEventListener("change", this.onRangeCheckboxChange.bind(this));
+		this.yaeCheckbox.addEventListener("change", this.onYAECheckboxChange.bind(this));
+	}
+
+	setYJK(yjk) {
+		this.yjk = new Vector4(yjk.x & -this.yStep, yjk.y, yjk.z, yjk.w);
+	}
+
+	getYJKInput() {
+		return new Vector4(
+			clamp(Math.round(this.yInput.value), 0, 31),
+			clamp(Math.round(this.jInput.value), -32, 31),
+			clamp(Math.round(this.kInput.value), -32, 31),
+			31
+		);
+	}
+
+	getRGBInput() {
+		return new Vector4(
+			clamp(Math.round(this.rInput.value), 0, 31),
+			clamp(Math.round(this.gInput.value), 0, 31),
+			clamp(Math.round(this.bInput.value), 0, 31),
+			31
+		);
+	}
+
+	onJKCanvasClick(event) {
+		this.setYJK(new Vector4(
+			this.yjk.x,
+			clamp(Math.floor(event.offsetX * 64 / this.jkCanvas.width - 32), -32, 31),
+			clamp(Math.floor(event.offsetY * 64 / this.jkCanvas.height - 32), -32, 31),
+			this.yjk.w
+		));
+		this.update();
+	}
+
+	onYCanvasClick(event) {
+		this.setYJK(new Vector4(
+			clamp(Math.floor(event.offsetY * 32 / this.yStep / this.yCanvas.height) * this.yStep, 0, 31),
+			this.yjk.y,
+			this.yjk.z,
+			this.yjk.w
+		));
+		this.update();
+	}
+
+	onJKCanvasArrowKey(x, y) {
+		this.setYJK(new Vector4(this.yjk.x, clamp(this.yjk.y + x, -32, 31), clamp(this.yjk.z + y, -32, 31), this.yjk.w));
+		this.update();
+	}
+
+	onYCanvasArrowKey(x, y) {
+		this.setYJK(new Vector4(clamp(this.yjk.x + y * this.yStep, 0, 31), this.yjk.y, this.yjk.z, this.yjk.w));
+		this.update();
+	}
+
+	onYJKInputChange(event) {
+		this.editingInput = event.type == "input" ? event.target : null;
+		this.setYJK(this.getYJKInput());
+		this.update();
+	}
+
+	onRGBInputChange(event) {
+		this.editingInput = event.type == "input" ? event.target : null;
+		this.setYJK(this.getRGBInput().toYJK());
+		this.update();
+	}
+
+	onRangeCheckboxChange(event) {
+		this.clipRange = this.rangeCheckbox.checked;
+		this.update();
+	}
+
+	onYAECheckboxChange(event) {
+		this.yStep = this.yaeCheckbox.checked ? 2 : 1;
+		this.setYJK(this.yjk);
+		this.update();
+	}
+
+	update() {
+		this.updateJKCanvas();
+		this.updateYCanvas();
+		this.updateInputs();
+	}
+
+	updateJKCanvas() {
+		const width = this.jkCanvas.width / 64;
+		const height = this.jkCanvas.height / 64;
+		const context = this.jkCanvas.getContext("2d");
+		context.clearRect(0, 0, this.jkCanvas.width, this.jkCanvas.height);
+		for (let k = -32; k < 32; ++k) {
+			for (let j = -32; j < 32; ++j) {
+				const rgb = new Vector4(this.yjk.x, j, k, this.yjk.w).toRGB();
+				const a = this.clipRange && !rgb.isInRange() ? 0.5 : 1;
+				const rgb24 = rgb.saturate().power(Vector4.scalar(this.gamma)).project(255);
+				context.fillStyle = `rgba(${rgb24.x}, ${rgb24.y}, ${rgb24.z}, ${a})`;
+				context.fillRect((j + 32) * width, (k + 32) * height, width, height);
+			}
+		}
+		context.lineWidth = 2;
+		context.strokeStyle = "rgb(255, 255, 255)"
+		context.strokeRect((this.yjk.y + 32) * width, (this.yjk.z + 32) * height, width, height);
+	}
+
+	updateYCanvas() {
+		const width = this.yCanvas.width;
+		const height = this.yCanvas.height / 32;
+		const context = this.yCanvas.getContext("2d");
+		context.clearRect(0, 0, this.yCanvas.width, this.yCanvas.height);
+		for (let y = 0; y < 32; y += this.yStep) {
+			const rgb = new Vector4(y, this.yjk.y, this.yjk.z, this.yjk.w).toRGB();
+			const a = this.clipRange && !rgb.isInRange() ? 0.5 : 1;
+			const rgb24 = rgb.saturate().power(Vector4.scalar(this.gamma)).project(255);
+			context.fillStyle = `rgba(${rgb24.x}, ${rgb24.y}, ${rgb24.z}, ${a})`;
+			context.fillRect(0, y * height, width, this.yStep * height);
+		}
+		context.lineWidth = 2;
+		context.strokeStyle = "rgb(255, 255, 255)"
+		context.strokeRect(1, this.yjk.x * height, width - 2, this.yStep * height);
+	}
+
+	updateInputs() {
+		this.yInput.step = this.yStep;
+		if (this.yInput != this.editingInput)
+			this.yInput.value = this.yjk.x;
+		if (this.jInput != this.editingInput)
+			this.jInput.value = this.yjk.y;
+		if (this.kInput != this.editingInput)
+			this.kInput.value = this.yjk.z;
+		if (this.rInput != this.editingInput)
+			this.rInput.value = this.yjk.toRGB().x;
+		if (this.gInput != this.editingInput)
+			this.gInput.value = this.yjk.toRGB().y;
+		if (this.bInput != this.editingInput)
+			this.bInput.value = this.yjk.toRGB().z;
+	}
+}
+
+function addSlideListener(element, listener) {
+	element.addEventListener("pointerdown", event => {
+		element.setPointerCapture(event.pointerId);
+		element.addEventListener("pointermove", listener);
+		listener(event);
+	});
+	element.addEventListener("pointerup", event => {
+		element.releasePointerCapture(event.pointerId);
+		element.removeEventListener("pointermove", listener);
+		listener(event);
+	});
+	element.addEventListener("pointercancel", event => {
+		element.releasePointerCapture(event.pointerId);
+		element.removeEventListener("pointermove", listener);
+	});
+}
+
+function addArrowKeyListener(element, listener) {
+	element.tabIndex = 0;
+	element.addEventListener("keydown", event => {
+		const x = event.code == "ArrowLeft" ? -1 : event.code == "ArrowRight" ? 1 : 0;
+		const y = event.code == "ArrowUp" ? -1 : event.code == "ArrowDown" ? 1 : 0;
+		if (x || y) {
+			listener(x, y);
+			event.preventDefault();
+		}
+	});
+}
+
+function addNumberInputListener(element, listener) {
+	element.addEventListener("change", listener);
+	element.addEventListener("input", event => {
+		if (element.value && !isNaN(element.value))
+			listener(event);
+	});
+}

          
A => articles/yjk/yjk.css +77 -0
@@ 0,0 1,77 @@ 
+.tool {
+	background-color: #5D5D7F;
+	box-shadow: 0 0 16px #3E3E55;
+	color: #EEF;
+	text-align: center;
+	border-radius: 7px;
+	margin: 8px 16px;
+	padding: 2px 2px;
+}
+
+.tool.visualizer {
+	width: 512px;
+	float: right;
+	clear: both;
+}
+
+.tool figcaption,
+.tool p {
+	color: #EEF;
+	margin: 8px 0;
+}
+
+.tool figcaption {
+	font-style: italic;
+}
+
+.tool .charts {
+	display: flex;
+	justify-content: center;
+}
+
+.tool canvas {
+	border-radius: 2px;
+	cursor: crosshair;
+	margin: 0 4px;
+}
+
+.tool canvas:focus {
+	outline: none;
+}
+
+.tool label {
+	user-select: none;
+}
+
+.tool .controls {
+	display: flex;
+	justify-content: space-evenly;
+}
+
+.tool .controls input[type=number] {
+	background-color: #7C7CAA; /* #5D5D7F; */
+	color: #EEF;
+	border: 0;
+	padding: 3px;
+	text-align: center;
+	border-radius: 4px;
+	font-size: small;
+}
+
+.tool .controls input[type=number]:invalid {
+	box-shadow: 0 0 1px 1px red;
+}
+
+@media screen and (max-width: 896px) {
+	.tool {
+		float: none;
+		margin-left: auto;
+		margin-right: auto;
+	}
+}
+
+@media screen and (max-width: 512px) {
+	.tool {
+		width: 384px;
+	}
+}

          
A => articles/yjk/yjk.js +188 -0
@@ 0,0 1,188 @@ 
+import { Vector4 } from "./vector.js";
+import { Matrix4x4 } from "./matrix.js";
+
+export const yjkToRGB = new Matrix4x4(
+	new Vector4(4, 4, 0, 0),
+	new Vector4(4, 0, 4, 0),
+	new Vector4(5, -2, -1, 0),
+	new Vector4(0, 0, 0, 4)
+);
+
+export const rgbToYJK = new Matrix4x4(
+	new Vector4(2, 1, 4, 0),
+	new Vector4(6, -1, -4, 0),
+	new Vector4(-2, 7, -4, 0),
+	new Vector4(0, 0, 0, 8)
+);
+
+export const yjkYAEToRGB = new Matrix4x4(
+	new Vector4(8, 4, 0, 0),
+	new Vector4(8, 0, 4, 0),
+	new Vector4(10, -2, -1, 0),
+	new Vector4(0, 0, 0, 4)
+);
+
+export const rgbToYJKYAE = new Matrix4x4(
+	new Vector4(1, 0.5, 2, 0),
+	new Vector4(6, -1, -4, 0),
+	new Vector4(-2, 7, -4, 0),
+	new Vector4(0, 0, 0, 8)
+);
+
+export const yuvToRGB = new Matrix4x4(
+	new Vector4(4, 4, 0, 0),
+	new Vector4(5, -2, -1, 0),
+	new Vector4(4, 0, 4, 0),
+	new Vector4(0, 0, 0, 4)
+);
+
+export const rgbToYUV = new Matrix4x4(
+	new Vector4(2, 4, 1, 0),
+	new Vector4(6, -4, -1, 0),
+	new Vector4(-2, -4, 7, 0),
+	new Vector4(0, 0, 0, 8)
+);
+
+export const yuvYAEToRGB = new Matrix4x4(
+	new Vector4(8, 4, 0, 0),
+	new Vector4(10, -2, -1, 0),
+	new Vector4(8, 0, 4, 0),
+	new Vector4(0, 0, 0, 4)
+);
+
+export const rgbToYUVYAE = new Matrix4x4(
+	new Vector4(1, 2, 0.5, 0),
+	new Vector4(6, -4, -1, 0),
+	new Vector4(-2, -4, 7, 0),
+	new Vector4(0, 0, 0, 8)
+);
+
+export const yuvToRGB601 = new Matrix4x4(
+	new Vector4(1, 0, 0.701 / 0.615, 0),
+	new Vector4(1, -0.114 * 0.886 / 0.436 / 0.587, -0.299 * 0.701 / 0.615 / 0.587, 0),
+	new Vector4(1, 0.886 / 0.436, 0, 0),
+	new Vector4(0, 0, 0, 1)
+);
+
+export const rgbToYUV601 = new Matrix4x4(
+	new Vector4(0.299, 0.587, 0.114, 0),
+	new Vector4(-0.299 * 0.436 / 0.886, -0.587 * 0.436 / 0.886, 0.436, 0),
+	new Vector4(0.615, -0.587 * 0.615 / 0.701, -0.114 * 0.615 / 0.701, 0),
+	new Vector4(0, 0, 0, 1)
+);
+
+export class Image {
+	constructor(width, height) {
+		this.width = width;
+		this.height = height;
+	}
+
+	getPixel(x, y) {
+		throw new Error("Not implemented.");
+	}
+
+	map(fn) {
+		const data = [];
+		for (let y = 0; y < this.height; ++y)
+			for (let x = 0; x < this.width; ++x)
+				data.push(fn(this.getPixel(x, y), x, y));
+		return new ImageData(this.width, this.height, data);
+	}
+
+	filter(filter) {
+		const data = [];
+		for (let y = 0; y < this.height; ++y) {
+			for (let x = 0; x < this.width; ++x) {
+				let value = new Vector4(0, 0, 0, 0);
+				for (let i = 0; i < filter.kernel.length; ++i) {
+					const ix = x + i - filter.center;
+					if (ix >= 0 && ix < this.width)
+						value = value.add(this.getPixel(ix, y).project(filter.kernel[i]));
+				}
+				data.push(value);
+			}
+		}
+		return new ImageData(this.width, this.height, data);
+	}
+
+	subsampleChroma1(filter, toRGB) {
+		const filteredImage = this.filter(filter);
+		// const yjkToY601 = new Vector4(1.0285, 0.242, 0.5585, 0);
+		const yjkToY601 = rgbToYUV601.multiply(toRGB).row0;
+		const yjkLuma = yjk => yjk.dot(yjkToY601) / yjkToY601.x;
+		return this.map((yjk, x, y) => {
+			const yjkScaled = yjk.project(31);
+			const sample = filteredImage.getPixel(x & ~3, y).project(31);
+			return new Vector4(sample.x + yjkLuma(yjkScaled) - yjkLuma(sample), sample.y, sample.z, sample.w);
+		});
+	}
+
+	subsampleChroma2(filter, toRGB) {
+		const filteredImage = this.filter(filter);
+		const yjkLuminance = yjk => yjk.transform(toRGB).saturate().power(Vector4.scalar(1)).dot(rgbToYUV601.row0);
+		return this.map((yjk, x, y) => {
+			const yjkScaled = yjk.project(31);
+			const sample = filteredImage.getPixel(x & ~3, y).project(31);
+			const targetLuminance = yjkLuminance(yjkScaled);
+			let bestDistance = Infinity;
+			let bestY = 0;
+			for (let y = 0; y < 32; y++) {
+				const candidate = new Vector4(y, sample.y, sample.z, sample.w);
+				const distance = Math.abs(yjkLuminance(candidate) - targetLuminance);
+				if (distance < bestDistance) {
+					bestDistance = distance;
+					bestY = y;
+				}
+			}
+			return new Vector4(bestY, sample.y, sample.z, sample.w);
+		});
+	}
+}
+
+export class ImageData extends Image {
+	constructor(width, height, data) {
+		super(width, height);
+		if (data.length != width * height)
+			throw new Error("Incomplete data.");
+		this.data = data;
+	}
+
+	getPixel(x, y) {
+		return this.data[y * this.width + x];
+	}
+}
+
+export class CanvasImage extends ImageData {
+	constructor(canvas, x, y, width, height) {
+		const imageData = canvas.getContext("2d").getImageData(x || 0, y || 0, width || canvas.width, height || canvas.height).data;
+		const data = [];
+		for (let i = 0; i < imageData.length; i += 4) {
+			data.push(new Vector4(imageData[i], imageData[i + 1], imageData[i + 2], 255));
+		}
+		super(canvas.width, canvas.height, data);
+	}
+}
+
+export class Filter {
+	constructor(kernel, center) {
+		if (center < 0 || center >= kernel.length)
+			throw new Error("Center outside kernel range.");
+		this.kernel = kernel;
+		this.center = center;
+	}
+
+	normalize(w) {
+		const sum = this.kernel.reduce((accu, current) => accu + current);
+		return new Filter(this.kernel.map(value => value * w / sum), this.center);
+	}
+}
+
+export class BoxFilter extends Filter {
+	constructor(size, center) {
+		super(new Array(size).fill(1), center || 0);
+	}
+}
+
+export const identityFilter = new Filter([1], 0);
+export const boxFilter = new BoxFilter(4, 0);
+export const tentFilter = new Filter([1, 3, 4, 4, 5, 4, 4, 3, 1], 3);