diff --git a/docs/dev/notebooks/interactive_tools.ipynb b/docs/dev/notebooks/interactive_tools.ipynb index 721fa2db6ba..a46dc3593c5 100644 --- a/docs/dev/notebooks/interactive_tools.ipynb +++ b/docs/dev/notebooks/interactive_tools.ipynb @@ -20,7 +20,7 @@ " \n", " \n", @@ -42,6 +42,39 @@ "LetsPlot.setup_html()" ] }, + { + "cell_type": "code", + "execution_count": 2, + "id": "92f5b6fe-8797-4d9a-a8ec-7a0f9e0457cd", + "metadata": {}, + "outputs": [], + "source": [ + "def dump_plot(plot, display=None):\n", + " import json\n", + "\n", + " try:\n", + " import clipboard\n", + " except:\n", + " clipboard = None\n", + " \n", + " from lets_plot._type_utils import standardize_dict\n", + " \n", + " plot_dict = standardize_dict(plot.as_dict())\n", + " plot_json = json.dumps(plot_dict, indent=2)\n", + " \n", + " if clipboard:\n", + " clipboard.copy('')\n", + " clipboard.copy(str(plot_json))\n", + " else:\n", + " if display is None:\n", + " display = True\n", + " \n", + " if display:\n", + " print(plot_json)\n", + "\n", + " return plot\n" + ] + }, { "cell_type": "markdown", "id": "a0aa16b5-94a6-4691-8e58-48b4cc7f440d", @@ -52,20 +85,20 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "b6b03e95-28c7-47a9-9b82-0a9a07bfbb88", "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
\n", + "
\n", " " ], "text/plain": [ - "" + "" ] }, - "execution_count": 2, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -142,24 +183,39 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "9dcd1a07-db64-4e80-a76e-23690fa8eaef", "metadata": {}, "outputs": [], "source": [ - "countries = geocode_countries().get_boundaries()" + "world = geocode_countries().get_boundaries()\n", + "north_america = geocode_countries(['usa', 'canada', 'mexico']).get_boundaries()\n", + "australia = geocode_countries('australia').get_boundaries(3)\n", + "\n", + "world_map = ggplot() \\\n", + " + geom_map(aes(fill='country'), map=world, size=0.1, show_legend=False, tooltips=layer_tooltips().line(\"@country\"))\n", + "\n", + "north_america_map = ggplot() \\\n", + " + geom_map(aes(fill='country'), map=north_america, size=0.1, show_legend=False,\n", + " tooltips=layer_tooltips().line(\"@country\")) \\\n", + " + coord_map(xlim=[-180, -50])\n", + "\n", + "australia_map = ggplot() \\\n", + " + geom_map(aes(fill='country'), map=australia, size=0.1, show_legend=False,\n", + " tooltips=layer_tooltips().line(\"@country\")) \\\n", + " + coord_map(xlim=[110, None], ylim=[-45, None])" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "0b25fcbb-ca2d-488d-963b-3dc123d01555", "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
\n", + "
\n", " " ], "text/plain": [ - "" + "" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "ggplot() + geom_map(aes(fill='country'), map=countries, size=0.1, show_legend=False) + coord_map() + ggsize(1000, 800)" + "ggplot() + geom_map(aes(fill='country'), map=world, size=0.1, show_legend=False) + coord_map() + ggsize(1000, 800)" ] }, { @@ -231,7 +295,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "d26ecd00-fbdf-40e9-a3f0-05c921a1cec0", "metadata": {}, "outputs": [], @@ -241,14 +305,14 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "59360926-9bcd-4aff-87cd-9ced2c638752", "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
\n", + "
\n", " " ], "text/plain": [ - "" + "" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -334,14 +406,14 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "8f7fab7c-90db-459c-935a-4c8901b93636", "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
\n", + "
\n", " " ], "text/plain": [ - "" + "" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -431,111 +511,486 @@ }, { "cell_type": "markdown", - "id": "a595b496-ba17-429c-92ba-23c4608ff7e5", + "id": "d8a13f9c-7d81-47f9-b3e3-8bca1f160dff", "metadata": {}, "source": [ - "# Marginal layers" + "# Grids" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "3c9f7f20-8ddc-4801-8617-a33f1d27d0d1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gggrid([\n", + " world_map,\n", + " gggrid([\n", + " north_america_map,\n", + " australia_map\n", + " ])\n", + "], ncol=1)" ] }, { "cell_type": "markdown", - "id": "026ccabf-a964-42d2-891c-460e07164195", + "id": "7ecb327c-2ec0-44ca-85ad-3921c3bc9151", "metadata": {}, "source": [ - "Not yet supported" + "# Bunch" ] }, { "cell_type": "code", - "execution_count": 8, - "id": "6e36f19f-1bea-478f-a447-ce33672a1b0b", + "execution_count": 10, + "id": "5e4c5269-6f58-4401-9827-9a2238bc6abb", "metadata": {}, "outputs": [ { "data": { "text/html": [ - "
\n", + "
\n", " " - ], - "text/plain": [ - "" ] }, - "execution_count": 8, "metadata": {}, - "output_type": "execute_result" + "output_type": "display_data" } ], "source": [ - "np.random.seed(0)\n", - "\n", + "bunch = GGBunch()\n", + "bunch.add_plot(world_map, 0, 0, 800, 400)\n", + "bunch.add_plot(north_america_map, 0, 400, 400, 300)\n", + "bunch.add_plot(australia_map, 400, 400, 300, 300)\n", + "bunch.show()" + ] + }, + { + "cell_type": "markdown", + "id": "a595b496-ba17-429c-92ba-23c4608ff7e5", + "metadata": {}, + "source": [ + "# Marginal layers" + ] + }, + { + "cell_type": "markdown", + "id": "026ccabf-a964-42d2-891c-460e07164195", + "metadata": {}, + "source": [ + "Not yet supported" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "6e36f19f-1bea-478f-a447-ce33672a1b0b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "np.random.seed(0)\n", + "\n", "cov0=[[1, -.8],\n", " [-.8, 1]]\n", "cov1=[[ 10, .1],\n", @@ -553,6 +1008,1842 @@ "p = ggplot(data, aes(\"x\", \"y\", color=\"c\", fill=\"c\")) + geom_point()\n", "p + ggmarginal(\"tr\", layer=geom_density(alpha=0.3, show_legend=False))" ] + }, + { + "cell_type": "markdown", + "id": "bbe4f3d8-d96c-482b-82b3-8eb767426d98", + "metadata": {}, + "source": [ + "# livemap" + ] + }, + { + "cell_type": "markdown", + "id": "834e4884-8c08-4cab-abb6-492a3b16d704", + "metadata": {}, + "source": [ + "## Tooltips precision test (no hitbox simplification, tooltip must appear exactly at borders)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "31458e98-4ef3-4f88-89cd-78686734b4e7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x1 = [0, 0, 40, 40, 0]\n", + "y1 = [0, 40, 40, 0, 0]\n", + "g1 = ['a'] * len(x1)\n", + "\n", + "x2 = [it + 60 for it in x1]\n", + "y2 = y1\n", + "g2 = ['b'] * len(x2)\n", + "\n", + "poly = {\n", + " 'x': x1 + x2,\n", + " 'y': y1 + y2,\n", + " 'g': g1 + g2,\n", + "}\n", + "\n", + "ggplot() \\\n", + " + geom_livemap() \\\n", + " + geom_polygon(aes(x='x', y='y', fill='g'), data=poly, tooltips=layer_tooltips().line(\"^fill\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b68b642f-5e5e-4fea-ace4-3d8d0d03dcaa", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "c5113b60-588d-43d6-be8d-abaeb3825323", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ggplot() + geom_livemap() + geom_polygon(aes(fill='country'), size=0, map=world, show_legend=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "af71d61e-7821-4b79-a471-2411c0fbfaf7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gggrid([\n", + " ggplot() + geom_livemap() + geom_polygon(aes(fill='country'), size=0, map=world, show_legend=False, tooltips=layer_tooltips().line(\"^fill\")),\n", + " ggplot() + geom_livemap() + geom_polygon(aes(fill='country'), size=0, map=north_america, show_legend=False, tooltips=layer_tooltips().line(\"^fill\")),\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "3f98dd91-b54b-47a8-bd8b-091e3c562a85", + "metadata": {}, + "source": [ + "# Performance" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "4f9608e0-95f5-4971-aa61-2f942b190ff5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "np.random.seed(0)\n", + "\n", + "cov0=[[1, -.8],\n", + " [-.8, 1]]\n", + "cov1=[[ 10, .1],\n", + " [.1, .1]]\n", + "\n", + "n = 4000\n", + "\n", + "x0, y0 = np.random.multivariate_normal(mean=[-2,0], cov=cov0, size=n).T\n", + "x1, y1 = np.random.multivariate_normal(mean=[0,1], cov=cov1, size=n).T\n", + "\n", + "data = dict(\n", + " x = np.concatenate((x0,x1)),\n", + " y = np.concatenate((y0,y1)),\n", + " c = [\"A\"]*n + [\"B\"]*n\n", + ")\n", + "\n", + "p = ggplot(data, aes(\"x\", \"y\", color=\"c\", fill=\"c\")) + geom_point()\n", + "#p + ggmarginal(\"tr\", layer=geom_density(alpha=0.3, show_legend=False))\n", + "dump_plot(p)\n", + "\n", + "def plot(n):\n", + " n = int(n / 2)\n", + " np.random.seed(0)\n", + " \n", + " cov0=[[1, -.8],\n", + " [-.8, 1]]\n", + " cov1=[[ 10, .1],\n", + " [.1, .1]]\n", + "\n", + " \n", + " x0, y0 = np.random.multivariate_normal(mean=[-2,0], cov=cov0, size=n).T\n", + " x1, y1 = np.random.multivariate_normal(mean=[0,1], cov=cov1, size=n).T\n", + " \n", + " data = dict(\n", + " x = np.concatenate((x0,x1)),\n", + " y = np.concatenate((y0,y1)),\n", + " c = [\"A\"]*n + [\"B\"]*n\n", + " )\n", + " \n", + " return ggplot(data, aes(\"x\", \"y\", color=\"c\", fill=\"c\")) + geom_point() + ggtitle('n=' + str(n * 2))\n", + " \n", + "plot(2000)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "db25b134-294e-487d-ad48-bf7f4de796a3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "plot(100)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "4f767310-2cfa-4a4d-a18a-25bd6aacf2fd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "plot(1000)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "1cd096df-7c29-4c76-9cc1-571c53b48656", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "plot(10_000)" + ] + }, + { + "cell_type": "markdown", + "id": "b353fd5b-f447-470f-aaf1-b6de11c44101", + "metadata": {}, + "source": [ + "# Tooltips" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "2435f3aa-e5fd-4419-bf5b-1e7107779bd8", + "metadata": {}, + "outputs": [], + "source": [ + "x1 = [0, 0, 40, 40, 0]\n", + "y1 = [0, 40, 40, 0, 0]\n", + "g1 = ['a'] * len(x1)\n", + "\n", + "x2 = [60, 80, 100, 60]\n", + "y2 = [0, 40, 0, 0]\n", + "g2 = ['b'] * len(x2)\n", + "\n", + "x3 = [-120, -100, -80, -100, -120]\n", + "y3 = [ 20, 40, 20, 0, 20]\n", + "g3 = ['c'] * len(x3)\n", + "\n", + "poly = {\n", + " 'x': x1 + x2,\n", + " 'y': y1 + y2,\n", + " 'g': g1 + g2,\n", + "}\n", + "\n", + "p12 = ggplot() \\\n", + " + geom_polygon(\n", + " aes(\n", + " x=x1 + x2, \n", + " y=y1 + y2, \n", + " fill=g1 + g2\n", + " ),\n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " ) \\\n", + " + coord_fixed()\n", + "\n", + "p23 = ggplot() \\\n", + " + geom_polygon(\n", + " aes(x=x2 + x3, y=y2 + y3, fill=g2 + g3), \n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " ) \\\n", + " + coord_fixed()\n", + "\n", + "p13 = ggplot() \\\n", + " + geom_polygon(\n", + " aes(x=x1 + x3, y=y1 + y3, fill=g1 + g3), \n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " ) \\\n", + " + coord_fixed()\n", + "\n", + "# livemap\n", + "lp12 = ggplot() \\\n", + " + geom_livemap() \\\n", + " + geom_polygon(\n", + " aes(x=x1 + x2, y=y1 + y2, fill=g1 + g2),\n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " )\n", + "\n", + "lp23 = ggplot() \\\n", + " + geom_livemap() \\\n", + " + geom_polygon(\n", + " aes(x=x2 + x3, y=y2 + y3, fill=g2 + g3), \n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " )\n", + "\n", + "lp13 = ggplot() \\\n", + " + geom_livemap() \\\n", + " + geom_polygon(\n", + " aes(x=x1 + x3, y=y1 + y3, fill=g1 + g3), \n", + " tooltips=layer_tooltips().line(\"^fill\")\n", + " )\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "912def6e-a000-42e8-9ea7-5b001451568f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "p12" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "938d837d-1aa6-4d99-befd-494a4008348d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "p23" + ] + }, + { + "cell_type": "markdown", + "id": "b1c6fa14-35da-43cb-b8e9-af3b8571db2e", + "metadata": {}, + "source": [ + "### gggrid" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "dbbb9eeb-a971-414b-9b8b-1bdc764f3878", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gggrid([\n", + " p12,\n", + " gggrid([\n", + " p23, p13\n", + " ])\n", + "], ncol=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "65c91034-373c-4f21-b6a4-307966d35847", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gggrid([\n", + " lp12,\n", + " gggrid([\n", + " lp23, lp13\n", + " ])\n", + "], ncol=1)" + ] + }, + { + "cell_type": "markdown", + "id": "ae6feb9e-1072-4787-bda4-0900fab7e322", + "metadata": {}, + "source": [ + "### GGBunch" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "d013b6e1-6f73-4309-a8bb-ec42f4b5611c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "bunch = GGBunch()\n", + "\n", + "bunch.add_plot(p12, 0, 0, 600, 400)\n", + "bunch.add_plot(p23, 600, 400, 600, 400)\n", + "bunch.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "255b75a0-78e1-45fe-a225-29b46cb96d18", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "livemap_bunch = GGBunch()\n", + "\n", + "livemap_bunch.add_plot(lp12, 0, 0, 600, 400)\n", + "livemap_bunch.add_plot(lp23, 600, 400, 600, 400)\n", + "\n", + "livemap_bunch.show()" + ] + }, + { + "cell_type": "markdown", + "id": "15dbec7f-7f09-478d-9d12-2ebd586620ca", + "metadata": {}, + "source": [ + "### facet_wrap" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "8f8ed64a-a86f-46fc-8d7b-a2c4aba5f367", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "p12 + facet_wrap('fill', ncol=2, nrow=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "75e17055-afd7-4de9-8d9e-1d404515c496", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "lp12 + facet_wrap('fill', ncol=2, nrow=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "8b8b3790-e3b9-40f9-add9-822e66de9391", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "lp12 + ggtitle(\"Multi\\nline\\ntitle\")" + ] } ], "metadata": { @@ -571,7 +2862,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.18" + "version": "3.8.15" } }, "nbformat": 4, diff --git a/js-package/src/jsMain/kotlin/FigureModelJs.kt b/js-package/src/jsMain/kotlin/FigureModelJs.kt index 95f505ac0b2..f204a5820c0 100644 --- a/js-package/src/jsMain/kotlin/FigureModelJs.kt +++ b/js-package/src/jsMain/kotlin/FigureModelJs.kt @@ -10,6 +10,7 @@ import org.jetbrains.letsPlot.core.spec.Option.Plot.SPEC_OVERRIDE import org.jetbrains.letsPlot.platf.w3c.jsObject.dynamicFromAnyQ import org.jetbrains.letsPlot.platf.w3c.jsObject.dynamicObjectToMap import org.jetbrains.letsPlot.platf.w3c.jsObject.dynamicToAnyQ +import org.w3c.dom.Element import org.w3c.dom.HTMLElement @OptIn(ExperimentalJsExport::class) @@ -49,6 +50,7 @@ class FigureModelJs internal constructor( monolithicParameters.width, monolithicParameters.height, monolithicParameters.parentElement, + monolithicParameters.eventTarget, monolithicParameters.options ) @@ -89,5 +91,6 @@ internal class MonolithicParameters( val width: Double, val height: Double, val parentElement: HTMLElement, + val eventTarget: Element, val options: Map ) \ No newline at end of file diff --git a/js-package/src/jsMain/kotlin/FigureToHtml.kt b/js-package/src/jsMain/kotlin/FigureToHtml.kt index cd661fcc4bc..31cad87ad49 100644 --- a/js-package/src/jsMain/kotlin/FigureToHtml.kt +++ b/js-package/src/jsMain/kotlin/FigureToHtml.kt @@ -9,6 +9,7 @@ import kotlinx.dom.createElement import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.geometry.Vector +import org.jetbrains.letsPlot.commons.registration.CompositeRegistration import org.jetbrains.letsPlot.commons.registration.Registration import org.jetbrains.letsPlot.core.canvasFigure.CanvasFigure import org.jetbrains.letsPlot.core.interact.event.ToolEventDispatcher @@ -35,7 +36,8 @@ import org.w3c.dom.svg.SVGSVGElement internal class FigureToHtml( private val buildInfo: FigureBuildInfo, - private val containerElement: HTMLElement + private val containerElement: HTMLElement, + private val eventTarget: Element ) { private val parentElement: HTMLElement = if (buildInfo.isComposite) { @@ -52,6 +54,10 @@ internal class FigureToHtml( fun eval(): Result { val buildInfo = buildInfo.layoutedByOuterSize() + containerElement.style.apply { + width = "${buildInfo.layoutInfo.figureSize.x}px" + height = "${buildInfo.layoutInfo.figureSize.y}px" + } buildInfo.injectLiveMapProvider { tiles: List>, spec: Map -> val cursorServiceConfig = CursorServiceConfig() @@ -60,20 +66,23 @@ internal class FigureToHtml( } val svgRoot = buildInfo.createSvgRoot() - val toolEventDispatcher = if (svgRoot is CompositeFigureSvgRoot) { + val (toolEventDispatcher, eventsRegistration) = if (svgRoot is CompositeFigureSvgRoot) { processCompositeFigure( svgRoot, origin = null, // The topmost SVG - parentElement = parentElement + parentElement = parentElement, + eventTarget = eventTarget ) } else { processPlotFigure( - svgRoot as PlotSvgRoot, - parentElement = parentElement + svgRoot = svgRoot as PlotSvgRoot, + parentElement = parentElement, + eventTarget = eventTarget, + eventArea = buildInfo.bounds ) } - val registration = object : Registration() { + val domCleanupRegistration = object : Registration() { override fun doRemove() { while (containerElement.firstChild != null) { containerElement.removeChild(containerElement.firstChild!!) @@ -83,11 +92,14 @@ internal class FigureToHtml( return Result( toolEventDispatcher, - registration + CompositeRegistration().add( + eventsRegistration, + domCleanupRegistration + ) ) } - class Result( + data class Result( val toolEventDispatcher: ToolEventDispatcher, val figureRegistration: Registration ) @@ -96,10 +108,12 @@ internal class FigureToHtml( private fun processPlotFigure( svgRoot: PlotSvgRoot, parentElement: HTMLElement, - ): ToolEventDispatcher { + eventTarget: Element, + eventArea: DoubleRectangle + ): Pair { val plotContainer = PlotContainer(svgRoot) - val rootSVG: SVGSVGElement = buildPlotFigureSVG(plotContainer, parentElement) + val (rootSVG, cleanupRegistration) = buildPlotFigureSVG(plotContainer, parentElement, eventTarget, eventArea) rootSVG.style.setCursor(CssCursor.CROSSHAIR) // Livemap cursor pointer @@ -110,14 +124,15 @@ internal class FigureToHtml( } parentElement.appendChild(rootSVG) - return plotContainer.toolEventDispatcher + return plotContainer.toolEventDispatcher to cleanupRegistration } private fun processCompositeFigure( svgRoot: CompositeFigureSvgRoot, origin: DoubleVector?, parentElement: HTMLElement, - ): ToolEventDispatcher { + eventTarget: Element, + ): Pair { svgRoot.ensureContentBuilt() val rootSvgSvg: SvgSvgElement = svgRoot.svg @@ -151,15 +166,17 @@ internal class FigureToHtml( } processPlotFigure( svgRoot = figureSvgRoot, - parentElement = figureContainer + parentElement = figureContainer, + eventTarget = eventTarget, + eventArea = figureSvgRoot.bounds.add(origin) ) } else { figureSvgRoot as CompositeFigureSvgRoot - processCompositeFigure(figureSvgRoot, elementOrigin, parentElement) + processCompositeFigure(figureSvgRoot, elementOrigin, parentElement, eventTarget) } } - return UnsupportedToolEventDispatcher() + return UnsupportedToolEventDispatcher() to Registration.EMPTY } fun setupRootHTMLElement(element: HTMLElement, size: DoubleVector) { @@ -187,9 +204,10 @@ internal class FigureToHtml( private fun buildPlotFigureSVG( plotContainer: PlotContainer, - parentElement: Element - ): SVGSVGElement { - + parentElement: Element, + eventTarget: Element, + eventArea: DoubleRectangle, + ): Pair { val svg: SVGSVGElement = mapSvgToSVG(plotContainer.svg) if (plotContainer.isLiveMap) { @@ -198,7 +216,12 @@ internal class FigureToHtml( } } - plotContainer.mouseEventPeer.addEventSource(DomMouseEventMapper(svg)) + val plotMouseEventMapper = DomMouseEventMapper(eventTarget, eventArea) + + val eventsRegistration = CompositeRegistration() + eventsRegistration.add(Registration.from(plotMouseEventMapper)) + + plotContainer.mouseEventPeer.addEventSource(plotMouseEventMapper) plotContainer.liveMapFigures.forEach { liveMapFigure -> val bounds = (liveMapFigure as CanvasFigure).bounds().get() @@ -211,18 +234,19 @@ internal class FigureToHtml( setPosition(CssPosition.RELATIVE) } + val canvasMouseEventMapper = DomMouseEventMapper( + eventTarget, + DoubleRectangle( + eventArea.origin.add(bounds.origin.toDoubleVector()), + bounds.dimension.toDoubleVector() + ) + ) + eventsRegistration.add(Registration.from(canvasMouseEventMapper)) + val canvasControl = DomCanvasControl( myRootElement = liveMapDiv, size = Vector(bounds.dimension.x, bounds.dimension.y), - mouseEventSource = DomMouseEventMapper( - eventSource = svg, - bounds = DoubleRectangle.XYWH( - bounds.origin.x, - bounds.origin.y, - bounds.dimension.x, - bounds.dimension.y - ) - ) + mouseEventSource = canvasMouseEventMapper ) val liveMapReg = liveMapFigure.mapToCanvas(canvasControl) @@ -231,10 +255,10 @@ internal class FigureToHtml( liveMapDiv.onDisconnect(liveMapReg::dispose) } - return svg + return svg to eventsRegistration } - private fun HTMLElement.onDisconnect(onDisconnected: () -> Unit): Int { + private fun Node.onDisconnect(onDisconnected: () -> Unit): Int { fun checkConnection() { if (!isConnected) { onDisconnected() diff --git a/js-package/src/jsMain/kotlin/MonolithicJs.kt b/js-package/src/jsMain/kotlin/MonolithicJs.kt index 16c72ac9be3..0bd83fe8373 100644 --- a/js-package/src/jsMain/kotlin/MonolithicJs.kt +++ b/js-package/src/jsMain/kotlin/MonolithicJs.kt @@ -5,6 +5,7 @@ /* root package */ +import kotlinx.browser.document import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.logging.PortableLogging @@ -15,9 +16,7 @@ import org.jetbrains.letsPlot.core.util.MonolithicCommon import org.jetbrains.letsPlot.core.util.MonolithicCommon.PlotsBuildResult.Error import org.jetbrains.letsPlot.core.util.MonolithicCommon.PlotsBuildResult.Success import org.jetbrains.letsPlot.platf.w3c.jsObject.dynamicObjectToMap -import org.w3c.dom.HTMLElement -import org.w3c.dom.HTMLParagraphElement -import org.w3c.dom.get +import org.w3c.dom.* import sizing.SizingOption import sizing.SizingPolicy @@ -50,7 +49,11 @@ fun buildPlotFromRawSpecs( } else { emptyMap() } - buildPlotFromProcessedSpecsIntern(processedSpec, width, height, parentElement, options) + + val persistentDiv = document.createElement("div") as HTMLDivElement + + parentElement.appendChild(persistentDiv) + buildPlotFromProcessedSpecsIntern(processedSpec, width, height, persistentDiv, persistentDiv, options) } catch (e: RuntimeException) { handleException(e, parentElement) null @@ -83,7 +86,12 @@ fun buildPlotFromProcessedSpecs( } else { emptyMap() } - buildPlotFromProcessedSpecsIntern(processedSpec, width, height, parentElement, options) + + val persistentDiv = document.createElement("div") as HTMLDivElement + + parentElement.appendChild(persistentDiv) + + buildPlotFromProcessedSpecsIntern(processedSpec, width, height, persistentDiv, persistentDiv, options) } catch (e: RuntimeException) { handleException(e, parentElement) null @@ -95,6 +103,7 @@ internal fun buildPlotFromProcessedSpecsIntern( width: Double, height: Double, parentElement: HTMLElement, + eventTarget: Element, options: Map ): FigureModelJs? { @@ -145,23 +154,27 @@ internal fun buildPlotFromProcessedSpecsIntern( val figureModel = if (success.buildInfos.size == 1) { // a single figure val buildInfo = success.buildInfos[0] - val result = FigureToHtml(buildInfo, parentElement).eval() + val result = FigureToHtml(buildInfo, parentElement, eventTarget).eval() FigureModelJs( plotSpec, - MonolithicParameters(width, height, parentElement, options), + MonolithicParameters(width, height, parentElement, eventTarget, options), result.toolEventDispatcher, result.figureRegistration ) } else { // a bunch - buildGGBunchComponent(success.buildInfos, parentElement) + buildGGBunchComponent(success.buildInfos, parentElement, eventTarget) null } return figureModel } -fun buildGGBunchComponent(plotInfos: List, parentElement: HTMLElement) { +fun buildGGBunchComponent( + plotInfos: List, + parentElement: HTMLElement, + eventTarget: Element +) { val bunchBounds = plotInfos.map { it.bounds } .fold(DoubleRectangle(DoubleVector.ZERO, DoubleVector.ZERO)) { acc, bounds -> acc.union(bounds) @@ -179,7 +192,8 @@ fun buildGGBunchComponent(plotInfos: List, parentElement: HTMLE FigureToHtml( buildInfo = plotInfo, - containerElement = itemContainerElement + containerElement = itemContainerElement, + eventTarget = eventTarget ).eval() } diff --git a/platf-w3c/src/jsMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/dom/DomMouseEventMapper.kt b/platf-w3c/src/jsMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/dom/DomMouseEventMapper.kt index 82ea3ce6943..8e36e2df6d6 100644 --- a/platf-w3c/src/jsMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/dom/DomMouseEventMapper.kt +++ b/platf-w3c/src/jsMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/dom/DomMouseEventMapper.kt @@ -1,22 +1,24 @@ /* - * Copyright (c) 2023. JetBrains s.r.o. + * Copyright (c) 2024. JetBrains s.r.o. * Use of this source code is governed by the MIT license that can be found in the LICENSE file. */ package org.jetbrains.letsPlot.core.platf.dom import kotlinx.browser.document +import kotlinx.browser.window import org.jetbrains.letsPlot.commons.event.* import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.intern.observable.event.EventHandler +import org.jetbrains.letsPlot.commons.registration.CompositeRegistration +import org.jetbrains.letsPlot.commons.registration.Disposable import org.jetbrains.letsPlot.commons.registration.Registration -import org.jetbrains.letsPlot.core.platf.dom.DomEventUtil.getButton -import org.jetbrains.letsPlot.core.platf.dom.DomEventUtil.getModifiers import org.jetbrains.letsPlot.platf.w3c.dom.events.DomEventType import org.jetbrains.letsPlot.platf.w3c.dom.on import org.w3c.dom.Element import org.w3c.dom.events.WheelEvent +import kotlin.math.roundToInt typealias DomMouseEvent = org.w3c.dom.events.MouseEvent typealias DomWheelEvent = WheelEvent @@ -24,37 +26,56 @@ typealias DomWheelEvent = WheelEvent private const val ENABLE_DEBUG_LOG = false class DomMouseEventMapper( - private val eventSource: Element, - private val bounds: DoubleRectangle? = null, -) : MouseEventSource { + private val eventTarget: Element, + + // The area where the events are handled. + // The area is relative to the top-left corner of the event target. + private val eventArea: DoubleRectangle = DoubleRectangle.XYWH( + x = 0, + y = 0, + width = eventTarget.clientWidth.toDouble(), + height = eventTarget.clientHeight.toDouble() + ) +) : MouseEventSource, Disposable { + + private val regs = CompositeRegistration() private val mouseEventPeer = MouseEventPeer() - private var state: MouseState = HoverState() + private var state: MouseState = MouseOverState() set(value) { if (ENABLE_DEBUG_LOG) { - println("state($${this@DomMouseEventMapper.hashCode()}): ${field::class.simpleName} -> ${value::class.simpleName}") + println( + "state($${ + this@DomMouseEventMapper.hashCode().toString(36) + }): ${field::class.simpleName} -> ${value::class.simpleName}" + ) } field = value } init { - handle(DomEventType.CLICK) { state.onMouseEvent(DomEventType.CLICK, it) } - handle(DomEventType.DOUBLE_CLICK) { state.onMouseEvent(DomEventType.DOUBLE_CLICK, it) } - handle(DomEventType.MOUSE_ENTER) { state.onMouseEvent(DomEventType.MOUSE_ENTER, it) } - handle(DomEventType.MOUSE_LEAVE) { state.onMouseEvent(DomEventType.MOUSE_LEAVE, it) } - handle(DomEventType.MOUSE_DOWN) { state.onMouseEvent(DomEventType.MOUSE_DOWN, it) } - handle(DomEventType.MOUSE_UP) { state.onMouseEvent(DomEventType.MOUSE_UP, it) } - handle(DomEventType.MOUSE_MOVE) { state.onMouseEvent(DomEventType.MOUSE_MOVE, it) } - handle(DomEventType.MOUSE_WHEEL) { state.onMouseEvent(DomEventType.MOUSE_WHEEL, it) } + if (ENABLE_DEBUG_LOG) { + println("DomMouseEventMapper(${this.hashCode().toString(36)}): subarea=$eventArea") + } + + fun addHandler(eventSpec: DomEventType) { + eventTarget + .on(eventSpec, consumer = { mouseEvent -> state.onMouseEvent(eventSpec, mouseEvent) }) + .also(regs::add) + } + + addHandler(DomEventType.CLICK) + addHandler(DomEventType.DOUBLE_CLICK) + addHandler(DomEventType.MOUSE_ENTER) + addHandler(DomEventType.MOUSE_LEAVE) + addHandler(DomEventType.MOUSE_DOWN) + addHandler(DomEventType.MOUSE_UP) + addHandler(DomEventType.MOUSE_MOVE) + addHandler(DomEventType.MOUSE_WHEEL) } - private fun handle(eventSpec: DomEventType, handler: (DomMouseEvent) -> Unit) { - eventSource.on(eventSpec, consumer = { - val needHandle = bounds?.contains(DoubleVector(it.offsetX, it.offsetY)) ?: true - if (needHandle) { - handler(it) - } - }) + override fun addEventHandler(eventSpec: MouseEventSpec, eventHandler: EventHandler): Registration { + return mouseEventPeer.addEventHandler(eventSpec, eventHandler) } private fun dispatch(eventSpec: MouseEventSpec, domMouseEvent: DomMouseEvent) { @@ -70,15 +91,11 @@ class DomMouseEventMapper( // Now dragging outside the iframe can be tested. //domMouseEvent.preventDefault() // Fix for Safari to prevent selection when user drags outside a canvas - val targetClientOrigin = eventSource.getBoundingClientRect().let { DoubleVector(it.x, it.y) } - val targetAbsoluteOrigin = bounds?.origin ?: DoubleVector.ZERO - val eventClientCoord = DoubleVector(domMouseEvent.clientX.toDouble(), domMouseEvent.clientY.toDouble()) - val eventTargetCoord = eventClientCoord.subtract(targetClientOrigin).subtract(targetAbsoluteOrigin) - - val x = eventTargetCoord.x.toInt() - val y = eventTargetCoord.y.toInt() - val button = getButton(domMouseEvent) - val modifiers = getModifiers(domMouseEvent) + val coord = toEventTargetOffsetCoord(domMouseEvent).subtract(eventArea.origin) + val x = coord.x.roundToInt()//domMouseEvent.regionX.roundToInt() + val y = coord.y.roundToInt()//domMouseEvent.regionY.roundToInt() + val button = DomEventUtil.getButton(domMouseEvent) + val modifiers = DomEventUtil.getModifiers(domMouseEvent) val mouseEvent = when (domMouseEvent) { is WheelEvent -> MouseWheelEvent(x, y, button, modifiers, domMouseEvent.deltaY) @@ -92,9 +109,25 @@ class DomMouseEventMapper( } } + private fun inEventArea(e: DomMouseEvent): Boolean { + return toEventTargetOffsetCoord(e) in eventArea + } + + // Convert event coordinates to the local coordinate system of the event target + // (i.e. the top-left corner of the event target is (0, 0)) + // Can't use offsetX/Y because it's relative to the element under the mouse pointer, not the event target. + // This means that with gggrid even when the mouse is over the most right cell, offsetX is still + // in range (0, cell.width), not (cell.left, cell.right) + private fun toEventTargetOffsetCoord(e: DomMouseEvent): DoubleVector { + val offsetX = e.pageX - window.pageXOffset - eventTarget.getBoundingClientRect().left + val offsetY = e.pageY - window.pageYOffset - eventTarget.getBoundingClientRect().top + + return DoubleVector(offsetX, offsetY) + } + private abstract inner class MouseState { fun onMouseEvent(type: DomEventType, e: DomMouseEvent) { - log(type.name) + log("${type.name} at (${e.x}, ${e.y})") handleEvent(type, e) } @@ -102,64 +135,83 @@ class DomMouseEventMapper( fun log(str: String) { if (ENABLE_DEBUG_LOG) { - println("${this::class.simpleName}(${this@DomMouseEventMapper.hashCode()}): $str") + println("${this::class.simpleName}(${this@DomMouseEventMapper.hashCode().toString(36)}): $str") } } } - private inner class HoverState : MouseState() { + private inner class MouseOutsideState : MouseState() { override fun handleEvent(type: DomEventType, e: DomMouseEvent) { - if (type == DomEventType.MOUSE_DOWN) { - dispatch(MouseEventSpec.MOUSE_PRESSED, e) - state = ButtonDownState(eventCoord = DoubleVector(e.x, e.y)) - return + if (!inEventArea(e)) return + + // mouseover with already pressed button -> drag from another element -> not hover for this element + if (e.buttons > 0) return + + when (type) { + DomEventType.MOUSE_ENTER, DomEventType.MOUSE_MOVE -> { + dispatch(MouseEventSpec.MOUSE_ENTERED, e) + state = MouseOverState() + } + // Ignore buttons/leave events + } + } + } + + private inner class MouseOverState : MouseState() { + override fun handleEvent(type: DomEventType, e: DomMouseEvent) { + if (!inEventArea(e)) { + dispatch(MouseEventSpec.MOUSE_LEFT, e) + state = MouseOutsideState() } - // Any event with already pressed button -> drag from another element -> ignore events until buttons release - if (e.buttons > 0) { - state = ForeignDragging() + if (type == DomEventType.MOUSE_DOWN) { + dispatch(MouseEventSpec.MOUSE_PRESSED, e) + state = MousePressedState(eventCoord = DoubleVector(e.x, e.y)) return } when (type) { DomEventType.MOUSE_MOVE -> dispatch(MouseEventSpec.MOUSE_MOVED, e) - DomEventType.MOUSE_LEAVE -> dispatch(MouseEventSpec.MOUSE_LEFT, e) - DomEventType.MOUSE_ENTER -> dispatch(MouseEventSpec.MOUSE_ENTERED, e) + + DomEventType.MOUSE_LEAVE -> { + dispatch(MouseEventSpec.MOUSE_LEFT, e) + state = MouseOutsideState() + } + DomEventType.DOUBLE_CLICK -> dispatch(MouseEventSpec.MOUSE_DOUBLE_CLICKED, e) // wish can handle in ButtonDownState DomEventType.MOUSE_WHEEL -> dispatch(MouseEventSpec.MOUSE_WHEEL_ROTATED, e as DomWheelEvent) + DomEventType.MOUSE_ENTER -> {} // should be handled by OutsideState DomEventType.MOUSE_UP, DomEventType.CLICK -> {} // ignore to prevent ghost clicks on UI } } } - private inner class ButtonDownState( + private inner class MousePressedState( private val eventCoord: DoubleVector, private val draggingTriggerDistance: Double = 3.0 ) : MouseState() { override fun handleEvent(type: DomEventType, e: DomMouseEvent) { when (type) { - DomEventType.MOUSE_UP -> { - dispatch(MouseEventSpec.MOUSE_RELEASED, e) - } + DomEventType.MOUSE_UP -> dispatch(MouseEventSpec.MOUSE_RELEASED, e) // It's safe to set HoverState on CLICK as DOM raises CLICK event exactly after MOUSE_UP, DomEventType.CLICK -> { dispatch(MouseEventSpec.MOUSE_CLICKED, e) - state = HoverState() + state = MouseOverState() } DomEventType.MOUSE_MOVE -> { if (DoubleVector(e.x, e.y).subtract(eventCoord).length() > draggingTriggerDistance) { dispatch(MouseEventSpec.MOUSE_DRAGGED, e) - state = Dragging() + state = MouseDragState() } } } } } - private inner class Dragging : MouseState() { + private inner class MouseDragState : MouseState() { private var myDocumentMouseEventsRegistration = Registration.from( document.on(DomEventType.MOUSE_MOVE, ::onDocumentMouseMove), document.on(DomEventType.MOUSE_UP, ::onDocumentMouseUp) @@ -171,7 +223,7 @@ class DomMouseEventMapper( private fun onDocumentMouseUp(e: DomMouseEvent) { dispatch(MouseEventSpec.MOUSE_RELEASED, e) - state = HoverState() + state = MouseOverState() myDocumentMouseEventsRegistration.dispose() } @@ -180,17 +232,7 @@ class DomMouseEventMapper( } } - private inner class ForeignDragging : MouseState() { - override fun handleEvent(type: DomEventType, e: DomMouseEvent) { - if (e.buttons > 0) { - return - } - - state = HoverState() - } - } - - override fun addEventHandler(eventSpec: MouseEventSpec, eventHandler: EventHandler): Registration { - return mouseEventPeer.addEventHandler(eventSpec, eventHandler) + override fun dispose() { + regs.dispose() } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/DrawRectFeedback.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/DrawRectFeedback.kt index 05256370836..2f41775f50a 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/DrawRectFeedback.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/DrawRectFeedback.kt @@ -92,6 +92,8 @@ class DrawRectFeedback( onCompleted = { val (target, dragFrom, dragTo, _) = it + it.reset() + val selection = getSelection(dragFrom, dragTo, target) if (isSelectionAcceptable(selection)) { @@ -101,8 +103,6 @@ class DrawRectFeedback( decorationsLayer.children().remove(dragRectSvg) decorationsLayer.children().remove(selectionSvg) - - it.reset() }, onAborted = { it.reset() diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/PanGeomFeedback.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/PanGeomFeedback.kt index 9dc266378da..c935626f662 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/PanGeomFeedback.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/interact/PanGeomFeedback.kt @@ -31,8 +31,9 @@ class PanGeomFeedback( val viewport = InteractionUtil.viewportFromTransform(target.geomBounds, translate = dragDelta) val dataBounds = target.applyViewport(viewport) - onCompleted(dataBounds) it.reset() + + onCompleted(dataBounds) }, onAborted = { println("PanGeomFeedback abort.")