{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "71118fae-2b09-44f2-a874-0120854f98e5",
   "metadata": {},
   "source": [
    "## Design considerations\n",
    "The principle here is to fit a cubic rational bezier determine the chroma curve. We use L-C boundary control points $(0, 0)$, $(100, 0)$, with a single mid-point $(L^*, C_{L^*}^*)$, where $L^*$ is the lightness at which the *maximum, maximum* chroma $C_{L^*}^*$ occurs.\n",
    "\n",
    "We allow some slight adjustments to this scheme, like shifting the $L^*$ point and changing the weight of the mid-point in the bezier curve (producing sharper, more pointed curves). The general aim is to fit the existing v1.1.1 chroma curves with an explainable continuous model, which of course can be used to generate color points for any reqested lightness levels.\n",
    "\n",
    "### Future considerations\n",
    "I like the idea of the **MCL** scheme, not used here. This scheme used a standardized lightness midpoint where chroma was maximized (for each accent), but did not fix the curve itself. So accent chroma curves had different heights, but they rise and fall in the same places along the lightness spectrum. Each step can be seen to make the *same percentage jump* to the maximum, unifying the movements across accents (e.g., if yellow has max chroma of 0.1 and red of 0.4, the chroma value at lightness L is the same x% of each accent's respective chroma max). This was a step in the right direction compared to **UCC**, which used an *identical* chroma curve for all accents. So while **MCL** preserved more color identity, it still had distinct effects like washing out the orange too early. But I *do* like the idea of being able to have unified justification for the chroma curve jumps across all accents, and while **QBR** (below) is a faithful v111 reproduction, the curves are pretty distinct in terms of shape (due to the allowed weighting; some are sharper, some smoother).\n",
    "\n",
    "We can also constrain by `Lab` distance: at `L60` or so, we could look at `Lab` distance from the grey `L20` reference background and make sure all accents have the same distance (which is basically just trying to get a uniform contrast ratio). We might also look at `Lab` distance *between* accents and make sure that *relative distinction* is preserved as we move across lightness levels.\n",
    "\n",
    "TODO: symmetry around 45 -- 60, no matter what. Or decide on new distances, but flipping light/dark should have the same contrast. If that doesn't line up with lightness, so be it; need to find either Lab distance or whatever makes that symmetric."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "cd89e94a-d2f6-4c91-9c27-bdae00378834",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "from functools import cache\n",
    "\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from scipy.interpolate import interp1d, CubicSpline, BSpline\n",
    "\n",
    "from coloraide import Color\n",
    "\n",
    "@cache\n",
    "def max_C_Lh(L, h, space='srgb', eps=1e-6, tol=1e-9):\n",
    "    '''\n",
    "    Binary search for max chroma at fixed lightness and hue\n",
    "\n",
    "    Parameters:\n",
    "        L: lightness percentage\n",
    "    '''\n",
    "    def C_in_gamut(C):\n",
    "        return Color('oklch', [L/100, C, h]).convert(space).in_gamut(tolerance=tol)\n",
    "\n",
    "    lo, hi = 0.0, 0.1\n",
    "    while C_in_gamut(hi): hi *= 2\n",
    "    while hi - lo > eps:\n",
    "        m = (lo + hi) / 2\n",
    "        lo, hi = (m, hi) if C_in_gamut(m) else (lo, m)\n",
    "\n",
    "    Cmax = lo\n",
    "    c_oklch = Color('oklch', [L, Cmax, h])\n",
    "    c_srgb  = c_oklch.convert('srgb')\n",
    "    \n",
    "    return Cmax\n",
    "\n",
    "def quad_bezier_rational(P0, P1, P2, w, t):\n",
    "    t = np.asarray(t)[:, None]\n",
    "    num = (1-t)**2*P0 + 2*w*(1-t)*t*P1 + t**2*P2\n",
    "    den = (1-t)**2 + 2*w*(1-t)*t + t**2\n",
    "    \n",
    "    return num/den\n",
    "    \n",
    "def bezier_y_at_x(P0, P1, P2, w, x_query, n=400):\n",
    "    t = np.linspace(0, 1, n)\n",
    "    B = quad_bezier_rational(P0, P1, P2, w, t)\n",
    "    x_vals, y_vals = B[:, 0], B[:, 1]\n",
    "    \n",
    "    return np.interp(x_query, x_vals, y_vals)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "c7d2d1d9-dad9-4f8f-8417-c22d38f089f0",
   "metadata": {},
   "outputs": [],
   "source": [
    "# SET LIGHTNESS CONTROL POINTS\n",
    "# L_points = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 98]\n",
    "L_points = list(range(10, 98+1))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "e7a0fba9-d5e7-4493-8bd5-58d3f7c62304",
   "metadata": {},
   "outputs": [],
   "source": [
    "# FIXED MONOBIOME PARAMETERS\n",
    "L_resolution = 5  # step size along lightness dim\n",
    "L_space = np.arange(0, 100+L_resolution, L_resolution)\n",
    "\n",
    "monotone_C_map = {\n",
    "    \"alpine\": 0,\n",
    "    \"badlands\": 0.011,\n",
    "    \"chaparral\": 0.011,\n",
    "    \"savanna\": 0.011,\n",
    "    \"grassland\": 0.011,\n",
    "    \"tundra\": 0.011,\n",
    "}\n",
    "\n",
    "h_weights = {\n",
    "    \"red\": 3.5,\n",
    "    \"orange\": 3.5,\n",
    "    \"yellow\": 0.7,\n",
    "    \"green\": 0.95,\n",
    "    \"blue\": 1.0,\n",
    "}\n",
    "h_offsets = {\n",
    "    \"red\": -1,  # -5,\n",
    "    \"orange\": 0,  #-2,\n",
    "    \"yellow\": 0,\n",
    "    \"green\": 0,\n",
    "    \"blue\": 0,\n",
    "}\n",
    "\n",
    "monotone_h_map = {\n",
    "    \"alpine\": 0,\n",
    "    \"badlands\": 29,\n",
    "    \"chaparral\": 62.5,\n",
    "    \"savanna\": 104,\n",
    "    \"grassland\": 148,\n",
    "    \"tundra\": 262,\n",
    "}\n",
    "accent_h_map = {\n",
    "    \"red\": 29,\n",
    "    \"orange\": 62.5,\n",
    "    \"yellow\": 104,\n",
    "    \"green\": 148,\n",
    "    \"blue\": 262,\n",
    "}\n",
    "h_map = {**monotone_h_map, **accent_h_map}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "46b619a1-9118-4576-9100-c1265ccd15f7",
   "metadata": {},
   "outputs": [],
   "source": [
    "# compute C max values over each point in L space\n",
    "h_Lspace_Cmax = {\n",
    "    h_str: [max_C_Lh(_L, _h) for _L in L_space]\n",
    "    for h_str, _h in h_map.items()\n",
    "}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "0da9d0b5-fa60-4dd7-aa55-0b3c81e503fd",
   "metadata": {},
   "outputs": [],
   "source": [
    "# compute *unbounded* chroma curves for all hues\n",
    "h_L_points_C = {}\n",
    "h_ctrl_L_C = {}\n",
    "\n",
    "for h_str, _h in monotone_h_map.items():\n",
    "    h_L_points_C[h_str] = [monotone_C_map[h_str]]*len(L_points)\n",
    "    \n",
    "for h_str, _h in accent_h_map.items():\n",
    "    Lspace_Cmax = h_Lspace_Cmax[h_str]\n",
    "    \n",
    "    # get L value of max chroma; will be a bezier control\n",
    "    L_Cmax_idx = np.argmax(Lspace_Cmax)\n",
    "    L_Cmax = L_space[L_Cmax_idx]\n",
    "\n",
    "    # offset control point by any preset x-shift\n",
    "    L_Cmax += h_offsets[h_str]\n",
    "\n",
    "    # and get max C at the L offset\n",
    "    Cmax = max_C_Lh(L_Cmax, _h)\n",
    "\n",
    "    # set 3 control points\n",
    "    p_0 = np.array([0, 0])\n",
    "    p_Cmax = np.array([L_Cmax, Cmax])\n",
    "    p_100 = np.array([100, 0])\n",
    "    \n",
    "    B_L_points = bezier_y_at_x(p_0, p_Cmax, p_100, h_weights.get(h_str, 1), L_points)\n",
    "    h_L_points_C[h_str] = B_L_points\n",
    "    h_ctrl_L_C[h_str] = np.vstack([p_0, p_Cmax, p_100])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "56fd3826-c2b7-480e-ac42-a72e96c045f4",
   "metadata": {},
   "outputs": [],
   "source": [
    "# compute full set of final chroma curves\n",
    "h_LC_color_map = {}\n",
    "h_L_points_Cstar = {}\n",
    "\n",
    "for h_str, L_points_C in h_L_points_C.items():\n",
    "    _h = h_map[h_str]\n",
    "    h_L_points_Cstar[h_str] = [\n",
    "        min(_C, max_C_Lh(_L, _h))\n",
    "        for _L, _C in zip(L_points, L_points_C)\n",
    "    ]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "3c257cdb-7cb4-4aed-b1a4-bb560f4848b0",
   "metadata": {},
   "outputs": [],
   "source": [
    "# put together objects for output formats\n",
    "toml_lines = []\n",
    "oklch_hL_dict = {}\n",
    "\n",
    "for h_str, L_points_Cstar in h_L_points_Cstar.items():\n",
    "    _h = h_map[h_str]\n",
    "    toml_lines.append(f\"[{h_str}]\")\n",
    "    oklch_hL_dict[h_str] = {}\n",
    "    \n",
    "    for _L, _C in zip(L_points, L_points_Cstar):\n",
    "        oklch = Color('oklch', [_L/100, _C, _h])\n",
    "        srgb = oklch.convert('srgb')\n",
    "        \n",
    "        hex_str = srgb.to_string(hex=True)\n",
    "        oklch_str = oklch.to_string(percent=False)\n",
    "        \n",
    "        toml_lines.append(f'l{_L} = \"{hex_str}\"')\n",
    "        oklch_hL_dict[h_str][_L] = oklch_str\n",
    "        \n",
    "    toml_lines.append(\"\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "281de03a-1ba4-419a-a991-1a9be2a66667",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 61620 characters]",
      "text/plain": [
       "<Figure size 400x800 with 6 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "ax_h_map = {}\n",
    "fig, axes = plt.subplots(\n",
    "    len(monotone_h_map),\n",
    "    1,\n",
    "    sharex=True,\n",
    "    sharey=True,\n",
    "    figsize=(4, 8)\n",
    ")\n",
    "\n",
    "for i, h_str in enumerate(h_L_points_Cstar):\n",
    "    _h = h_map[h_str]\n",
    "    L_points_Cstar = h_L_points_Cstar[h_str]\n",
    "    L_space_Cmax = h_Lspace_Cmax[h_str]\n",
    "    \n",
    "    if _h not in ax_h_map:\n",
    "        ax_h_map[_h] = axes[i]\n",
    "    ax = ax_h_map[_h]\n",
    "\n",
    "    # plot Cmax and Cstar\n",
    "    ax.plot(L_space, L_space_Cmax, c=\"b\", alpha=0.2)\n",
    "    ax.plot(L_points, L_points_Cstar, alpha=0.7)\n",
    "    \n",
    "    ax.title.set_text(f\"Hue [${_h}$]\")\n",
    "    \n",
    "axes[-1].set_xlabel(\"Lightness (%)\")\n",
    "axes[-1].set_xticks([L_points[0], L_points[-1]])\n",
    "\n",
    "fig.tight_layout()\n",
    "fig.subplots_adjust(top=0.9)\n",
    "\n",
    "plt.suptitle(\"$C^*$ curves for hue groups\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "b52e351a-fa3c-4d60-877c-75a938b52289",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 110852 characters]",
      "text/plain": [
       "<Figure size 600x1000 with 6 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "v111_L_space = list(range(15, 95+1, 5))\n",
    "v111_hC_points = {\n",
    "    \"red\": [0.058, 0.074, 0.092, 0.11, 0.128, 0.147, 0.167, 0.183, 0.193, 0.193, 0.182, 0.164, 0.14, 0.112, 0.081, 0.052, 0.024],\n",
    "    \"orange\": [0.030, 0.038, 0.046, 0.058, 0.07, 0.084, 0.1, 0.114, 0.125, 0.134, 0.138, 0.136, 0.128, 0.112, 0.092, 0.064, 0.032],\n",
    "    \"yellow\": [0.02, 0.024, 0.03, 0.036, 0.044, 0.05, 0.06, 0.068, 0.076, 0.082, 0.088, 0.088, 0.086, 0.082, 0.072, 0.058, 0.04],\n",
    "    \"green\": [0.0401, 0.048, 0.056, 0.064, 0.072, 0.08, 0.09, 0.098, 0.104, 0.108, 0.11, 0.108, 0.102, 0.094, 0.084, 0.072, 0.05],\n",
    "    \"blue\": [0.06, 0.072, 0.084, 0.096, 0.106, 0.116, 0.124, 0.13, 0.132, 0.128, 0.122, 0.11, 0.096, 0.08, 0.064, 0.044, 0.023],\n",
    "}\n",
    "\n",
    "ax_h_map = {}\n",
    "fig, axes = plt.subplots(\n",
    "    len(monotone_h_map),\n",
    "    1,\n",
    "    sharex=True,\n",
    "    sharey=True,\n",
    "    figsize=(6, 10)\n",
    ")\n",
    "\n",
    "for i, h_str in enumerate(h_L_points_Cstar):\n",
    "    _h = h_map[h_str]\n",
    "    L_points_Cstar = h_L_points_Cstar[h_str]\n",
    "    L_space_Cmax = h_Lspace_Cmax[h_str]\n",
    "    \n",
    "    if _h not in ax_h_map:\n",
    "        ax_h_map[_h] = axes[i]\n",
    "    ax = ax_h_map[_h]\n",
    "\n",
    "    # plot Cmax and Cstar\n",
    "    ax.plot(L_space, L_space_Cmax, c=\"b\", alpha=0.2)\n",
    "    ax.plot(L_points, L_points_Cstar, alpha=0.7)\n",
    "\n",
    "    if h_str in v111_hC_points:\n",
    "        ax.scatter(v111_L_space, v111_hC_points[h_str], s=4)\n",
    "        \n",
    "    if h_str in h_ctrl_L_C:\n",
    "        cpts = h_ctrl_L_C[h_str]\n",
    "        cpt_x, cpt_y = cpts[:, 0], cpts[:, 1]\n",
    "        h_w = h_weights.get(h_str, 1)\n",
    "        \n",
    "        P0, P1, P2 = cpts[0], cpts[1], cpts[2]\n",
    "        d0 = 2 * h_w * (P1 - P0)\n",
    "        d2 = 2 * h_w * (P2 - P1)\n",
    "\n",
    "        handle_scale = 0.25\n",
    "        H0 = P0 + handle_scale * d0\n",
    "        H2 = P2 - handle_scale * d2\n",
    "    \n",
    "        # ax.plot([P0[0], H0[0]], [P0[1], H0[1]], color='tab:blue', lw=1)\n",
    "        # ax.plot([P2[0], H2[0]], [P2[1], H2[1]], color='tab:orange', lw=1)\n",
    "        \n",
    "        ax.plot(cpt_x, cpt_y, '--', color='gray', lw=1, label='control polygon')\n",
    "        ax.scatter(cpt_x, cpt_y, color='red', zorder=5, s=2)\n",
    "        \n",
    "    ax.title.set_text(f\"Hue [${_h}$]\")\n",
    "    \n",
    "axes[-1].set_xlabel(\"Lightness (%)\")\n",
    "axes[-1].set_xticks([L_points[0], 50, 65, L_points[-1]])\n",
    "\n",
    "fig.tight_layout()\n",
    "fig.subplots_adjust(top=0.9)\n",
    "\n",
    "plt.suptitle(\"$C^*$ curves for hue groups + v111 5% lightness\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "1e2a1a5a-7724-459a-9a1e-69ab0f421c3b",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 34544 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "fig, ax = plt.subplots(1, 1, figsize=(8, 6))\n",
    "\n",
    "for h_str in h_L_points_Cstar:\n",
    "    if h_str not in accent_h_map:\n",
    "        continue\n",
    "    ax.fill_between(L_points, h_L_points_Cstar[h_str], alpha=0.5, color=h_str)\n",
    "    \n",
    "ax.set_xlabel(\"Lightness (%)\")\n",
    "ax.set_xticks([L_points[0], 45, 50, 55,   60, 65, 70, L_points[-1]])\n",
    "ax.set_xlim(45, 70)\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "ef0dd0d9-a267-494f-b9c6-90d9ab0e6564",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[TOML] written\n",
      "[JSON] written\n"
     ]
    }
   ],
   "source": [
    "# write files -- QBR = \"quadratic bezier rational\"\n",
    "PALETTE_DIR = \"palettes\"\n",
    "\n",
    "toml_content = '\\n'.join(toml_lines)\n",
    "with Path(PALETTE_DIR, 'monobiome-vQBR.toml').open('w') as f:\n",
    "    f.write(toml_content)\n",
    "print(\"[TOML] written\")\n",
    "    \n",
    "with Path(PALETTE_DIR, 'monobiome-vQBR-oklch.json').open('w') as f:\n",
    "    json.dump(oklch_hL_dict, f)\n",
    "print(\"[JSON] written\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10295cea-39e1-421d-b523-2eda615b0abd",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "monobiome",
   "language": "python",
   "name": "monobiome"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
