{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "e151bcbd-93f2-41ec-8c2d-3666cec26af9",
   "metadata": {},
   "source": [
    "**This notebook**: same as the dedicated QBR explorations, but within key lightness ranges (i.e., 45-70), attempt to normalize the accent chroma curves. The idea: there's a consistent order to the *distinction* between accents. Since chroma is the only free parameter at this point that determines perceptual distinction, and *fixing* that chroma is out of the question (leads to dull schemes, on the whole), we'd like the *relative distinction/role* of each accent to remain consistent across lightness values."
   ]
  },
  {
   "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": "b0617a01-cb05-4950-9bc3-f4fd0de63f56",
   "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.2,\n",
    "    \"orange\": 3.8,  # 3.6\n",
    "    \"yellow\": 3.8,  # 4.0\n",
    "    \"green\": 3.8,\n",
    "    \"blue\": 3.2,  # 3.8\n",
    "}\n",
    "h_L_offsets = {\n",
    "    \"red\": 1,  # -1,\n",
    "    \"orange\": -8,  # -3,\n",
    "    \"yellow\": -18,  # -8\n",
    "    \"green\": -16,  # -8\n",
    "    \"blue\": 10,  # 14\n",
    "}\n",
    "h_C_offsets = {\n",
    "    \"red\": -0.01,  # 0\n",
    "    \"orange\": -0.01,  # -0.02\n",
    "    \"yellow\": -0.07,  # -0.08\n",
    "    \"green\": -0.09,  # -0.105\n",
    "    \"blue\": 0.0,  # 0.01\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] = np.array([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_L_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; shift by any global linear offest\n",
    "    C_offset = h_C_offsets.get(h_str, 0)\n",
    "    \n",
    "    p_0 = np.array([0, 0])\n",
    "    p_Cmax = np.array([L_Cmax, Cmax + C_offset])\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; limits every point to in-gamut max\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",
    "\n",
    "    h_L_points_Cstar[h_str] = [\n",
    "        max(0, 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",
    "        \n",
    "        l, c, h = oklch.convert('oklch').coords()\n",
    "        # oklch_str = oklch.to_string(percent=False)\n",
    "        oklch_str = f\"oklch({l*100:.1f}% {c:.4f} {h:.1f})\"\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, 61144 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, 111536 characters]",
      "text/plain": [
       "<Figure size 500x1000 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=(5, 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, label='Cmax')\n",
    "    ax.plot(L_points, L_points_Cstar, alpha=0.7, label='C*')\n",
    "\n",
    "    if h_str in v111_hC_points:\n",
    "        ax.scatter(v111_L_space, v111_hC_points[h_str], s=4, label='Cv111')\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='Bezier polygon')\n",
    "        ax.scatter(cpt_x, cpt_y, color='red', zorder=5, s=2, label='Control points')\n",
    "        \n",
    "    ax.title.set_text(f\"Hue [${_h}$]\")\n",
    "    \n",
    "axes[-1].set_ylabel(\"Chroma (C)\")\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",
    "handles, labels = axes[-1].get_legend_handles_labels()\n",
    "unique = dict(zip(labels, handles))\n",
    "fig.legend(unique.values(), unique.keys(), loc='lower center', bbox_to_anchor=(0.5, -0.06), ncol=3)\n",
    "\n",
    "plt.suptitle(\"$C^*$ curves for hue groups + v111 5% lightness\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "1e2a1a5a-7724-459a-9a1e-69ab0f421c3b",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 65752 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from numpy import arctan2, degrees\n",
    "\n",
    "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.2, color='grey', label=h_str)\n",
    "\n",
    "    x, y = L_points, h_L_points_Cstar[h_str]\n",
    "    n = int(0.5*len(x))\n",
    "    ax.text(x[n], y[n]-0.01, h_str, rotation=10, va='center', ha='left')\n",
    "    \n",
    "ax.set_xlabel(\"Lightness (%)\")\n",
    "ax.set_xticks([L_points[0], 45, 50, 55, 60, 65, 70, L_points[-1]])\n",
    "plt.suptitle(\"$C^*$ curves (v1.2.0)\")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "d137161e-1628-4789-b6ff-b82925b989e5",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 55996 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "from numpy import arctan2, degrees\n",
    "\n",
    "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(v111_L_space, v111_hC_points[h_str], alpha=0.2, color='grey', label=h_str)\n",
    "\n",
    "    x, y = v111_L_space, v111_hC_points[h_str]\n",
    "    n = int(0.46*len(x))\n",
    "    ax.text(x[n], y[n]-0.005, h_str, rotation=30, va='center', ha='left')\n",
    "    \n",
    "ax.set_xlabel(\"Lightness (%)\")\n",
    "ax.set_xticks([L_points[0], 45, 50, 55, 60, 65, 70, L_points[-1]])\n",
    "plt.suptitle(\"$C^*$ curves (v1.1.1)\")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "d72e7f0b-a1a4-46f1-84fb-752229afafb5",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 41100 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.2, color=h_str)\n",
    "    \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",
    "plt.suptitle(\"$C^*$ curves -- mid-range close-up (v1.2.0)\")\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "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-vQBRsn.toml').open('w') as f:\n",
    "    f.write(toml_content)\n",
    "print(\"[TOML] written\")\n",
    "    \n",
    "with Path(PALETTE_DIR, 'monobiome-vQBRsn-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
}
