chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Data Architecture
Application developers can choose from
[Client (WebAssembly)](./architecture/client_only.md),
[Server (Python/Node)](./architecture/server_only.md) or
[Client/Server Replicated](./architecture/client_server.md) designs to bind
data, and a web application can use one or a mix of these designs as needed. By
serializing to Apache Arrow, tables are duplicated and synchronized across
runtimes efficiently.
Perspective is a multi-language platform. The examples in this section use
Python and JavaScript as an example, but the same general principles apply to
any `Client`/`Server` combination.
<img src="./architecture/architecture.svg" />
@@ -0,0 +1,197 @@
digraph G {
bgcolor=transparent
graph [rankdir="LR" fontname="helvetica" labeljust="l"]
node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white];
edge [color="#EDEBDF" arrowsize=0.8]
subgraph cluster_11 {
label="\lPython Server";
fontcolor=gray30
margin=10
color=none
subgraph cluster_thread_2 {
graph [
label="\lPerspectiveManager - Thread 2";
style=filled
fillcolor="#91A4A8"
color=none
fontcolor="#EDEBDF"
fontsize=10
margin=10
]
table_thread_2 [
label="table(df)"
width=1
color=none
fillcolor="#E6E2DA"
]
view_thread_2 [
label="view({\l group_by: [\"State\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
view_thread_2_2 [
label="view({\l group_by: [\"City\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table_thread_2 -> view_thread_2_2;
table_thread_2 -> view_thread_2;
}
subgraph cluster_thread_1 {
graph [
label="\lPerspectiveManager - Thread 1";
style=filled
fillcolor="#91A4A8"
color=none
fontcolor="#EDEBDF"
fontsize=10
margin=10
]
table_thread_1 [
label="table(arrow)"
width=1
color=none
fillcolor="#E6E2DA"
]
view_thread_1 [
label="view({\l group_by:'[\"State\"]\l split_by:'[\"Segment\"]'\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table_thread_1 -> view_thread_1;
}
}
subgraph cluster_browser {
graph [
label="\lBrowser";
color="#CADEE1";
margin=10
style=filled;
fontcolor=gray30
]
subgraph cluster_2 {
graph [
label="\lWebWorker 1";
style=filled
margin=10
fillcolor="#2D4C68"
color=none
fontcolor="#EDEBDF"
fontsize=10
]
table1 [
label="table(csv)"
width=1
color=none
fillcolor="#E6E2DA"
]
table_remote_view [
label="table(json)"
width=1
color=none
fillcolor="#E6E2DA"
]
view1 [
label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
view2 [
label="view({\l group_by: [\"Sub-Category\"]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
view3 [
label="view()\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table1 -> {view1 view2};
table_remote_view -> view3;
}
subgraph cluster_webworker2 {
graph [
label="\lWebWorker 2";
style=filled
margin=10
fillcolor="#2D4C68"
color=none
fontcolor="#EDEBDF"
fontsize=10
]
table12 [
label="table(...)"
width=1
color=none
fillcolor="#E6E2DA"
]
view12 [
label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table12 -> {view12} [color="#E6E2DA"];
}
view_thread_2 -> table12 [penwidth=2 style=dashed arrowhead=none color="#D1A043"];
view1 -> viewer1 [penwidth=2 style=dashed arrowhead=none color="#666"];
view2 -> viewer2 [penwidth=2 style=dashed arrowhead=none color="#666"];
view3 -> viewer3 [penwidth=2 style=dashed arrowhead=none color="#666"];
subgraph cluster_41 {
graph [
label="\l<html>";
color=none
fillcolor=white
fontcolor=gray30
fontsize=10
fontname="monospace" fontsize=8 color=none
]
viewer1 [
label = "<perspective-viewer\l view=\"Y Bar\"\l row-pivots='[\"Category\"]'\l filters='[[\"State\",\"==\",\"Texas\"]]'>\l"
width=2.8
];
viewer2 [
label = "<perspective-viewer\l view=\"xy_scatter\"\l row-pivots='[\"Sub-Category\"]'>\l"
width=2.8
color=lightgrey
];
viewer3 [
label = "<perspective-viewer\l view=\"grid\">\l"
width=2.8
];
viewerN [
style=invis
]
viewer5 [
label = "<perspective-viewer\l view=\"heatmap\"\l row-pivots='[\"State\"]\l column-pivots='[\"Segment\"]'>\l"
width=2.8
];
viewerZ [
style=invis
height=0.3
]
viewer4 [
label = "<perspective-viewer\l view=\"heatmap\"\l row-pivots='[\"State\"]\l column-pivots='[\"Segment\"]'>\l"
width=2.8
];
view_thread_1 -> viewer4 [penwidth=2 style=dashed arrowhead=none color="#D1A043" constraint=false];
view12 -> viewer5 [penwidth=2 style=dashed arrowhead=none color="#666"];
}
}
}
@@ -0,0 +1,66 @@
digraph G {
bgcolor=transparent
graph [rankdir="LR" fontname="helvetica" labeljust="l"]
node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white];
edge [color="#EDEBDF" arrowsize=0.8]
subgraph cluster_browser {
graph [
label="\lBrowser";
color="#CADEE1";
margin=10
style=filled;
fontcolor=gray30
]
subgraph cluster_2 {
graph [
label="\lWebWorker 1";
style=filled
margin=10
fillcolor="#2D4C68"
color=none
fontcolor="#EDEBDF"
fontsize=10
]
table1 [
label="table(csv)"
width=1
color=none
fillcolor="#E6E2DA"
]
view2 [
label="view({\l group_by: [\"Sub-Category\"]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table1 -> {view2};
}
view2 -> viewer2 [penwidth=2 style=dashed arrowhead=none color="#666"];
subgraph cluster_41 {
graph [
label="\l<html>";
color=none
fillcolor=white
fontcolor=gray30
fontsize=10
fontname="monospace" fontsize=8 color=none
]
viewer2 [
label = "<perspective-viewer\l view=\"xy_scatter\"\l row-pivots='[\"Sub-Category\"]'>\l"
width=2.8
color=lightgrey
];
}
}
}
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 5.0.0 (20220707.1540)
-->
<!-- Title: G Pages: 1 -->
<svg width="590pt" height="170pt"
viewBox="0.00 0.00 590.00 170.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 166)">
<title>G</title>
<g id="clust1" class="cluster">
<title>cluster_browser</title>
<polygon fill="#cadee1" stroke="#cadee1" points="8,-8 8,-154 574,-154 574,-8 8,-8"/>
<text text-anchor="middle" x="41" y="-122.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Browser</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_2</title>
<polygon fill="#2d4c68" stroke="transparent" points="18,-18 18,-105 326,-105 326,-18 18,-18"/>
<text text-anchor="middle" x="55.5" y="-81" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">WebWorker 1</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_41</title>
<polygon fill="white" stroke="transparent" points="342,-18 342,-100 564,-100 564,-18 342,-18"/>
<text text-anchor="middle" x="364" y="-80.6" font-family="monospace" font-size="8.00" fill="#4d4d4d">&lt;html&gt;</text>
</g>
<!-- table1 -->
<g id="node1" class="node">
<title>table1</title>
<polygon fill="#e6e2da" stroke="transparent" points="100,-64 28,-64 28,-28 100,-28 100,-64"/>
<text text-anchor="middle" x="64" y="-44.1" font-family="monospace" font-size="8.00">table(csv)</text>
</g>
<!-- view2 -->
<g id="node2" class="node">
<title>view2</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-64 136,-64 136,-28 316,-28 316,-64"/>
<text text-anchor="start" x="144" y="-53.1" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-44.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;Sub&#45;Category&quot;]</text>
<text text-anchor="start" x="144" y="-35.1" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table1&#45;&gt;view2 -->
<g id="edge1" class="edge">
<title>table1&#45;&gt;view2</title>
<path fill="none" stroke="#edebdf" d="M100.11,-46C108.52,-46 117.91,-46 127.66,-46"/>
<polygon fill="#edebdf" stroke="#edebdf" points="127.91,-48.8 135.91,-46 127.91,-43.2 127.91,-48.8"/>
</g>
<!-- viewer2 -->
<g id="node3" class="node">
<title>viewer2</title>
<polygon fill="white" stroke="lightgrey" points="554,-64 352,-64 352,-28 554,-28 554,-64"/>
<text text-anchor="start" x="360" y="-53.1" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="360" y="-44.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;xy_scatter&quot;</text>
<text text-anchor="start" x="360" y="-35.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;Sub&#45;Category&quot;]&#39;&gt;</text>
</g>
<!-- view2&#45;&gt;viewer2 -->
<g id="edge2" class="edge">
<title>view2&#45;&gt;viewer2</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M316.25,-46C327.9,-46 339.91,-46 351.73,-46"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,66 @@
digraph G {
bgcolor=transparent
graph [rankdir="LR" fontname="helvetica" labeljust="l"]
node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white];
edge [color="#EDEBDF" arrowsize=0.8]
subgraph cluster_11 {
label="\lPython Server";
fontcolor=gray30
margin=10
color=none
subgraph cluster_thread_1 {
graph [
label="\lPerspectiveManager - Thread 1";
style=filled
fillcolor="#91A4A8"
color=none
fontcolor="#EDEBDF"
fontsize=10
margin=10
]
table_thread_1 [
label="table(arrow)"
width=1
color=none
fillcolor="#E6E2DA"
]
view_thread_1 [
label="view({\l group_by:'[\"State\"]\l split_by:'[\"Segment\"]'\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table_thread_1 -> view_thread_1;
}
}
subgraph cluster_browser {
graph [
label="\lBrowser";
color="#CADEE1";
margin=10
style=filled;
fontcolor=gray30
]
subgraph cluster_41 {
graph [
label="\l<html>";
color=none
fillcolor=white
fontcolor=gray30
fontsize=10
fontname="monospace" fontsize=8 color=none
]
viewer4 [
label = "<perspective-viewer\l view=\"heatmap\"\l row-pivots='[\"State\"]\l column-pivots='[\"Segment\"]'>\l"
width=2.8
];
view_thread_1 -> viewer4 [penwidth=2 style=dashed arrowhead=none color="#D1A043"];
}
}
}
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 5.0.0 (20220707.1540)
-->
<!-- Title: G Pages: 1 -->
<svg width="602pt" height="178pt"
viewBox="0.00 0.00 602.00 178.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 174)">
<title>G</title>
<g id="clust1" class="cluster">
<title>cluster_11</title>
<polygon fill="transparent" stroke="transparent" points="8,-8 8,-162 336,-162 336,-8 8,-8"/>
<text text-anchor="middle" x="59" y="-130.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Python Server</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_thread_1</title>
<polygon fill="#91a4a8" stroke="transparent" points="18,-18 18,-113 326,-113 326,-18 18,-18"/>
<text text-anchor="middle" x="94.5" y="-89" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">PerspectiveManager &#45; Thread 1</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_browser</title>
<polygon fill="#cadee1" stroke="#cadee1" points="344,-8 344,-157 586,-157 586,-8 344,-8"/>
<text text-anchor="middle" x="377" y="-125.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Browser</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_41</title>
<polygon fill="white" stroke="transparent" points="354,-18 354,-108 576,-108 576,-18 354,-18"/>
<text text-anchor="middle" x="376" y="-88.6" font-family="monospace" font-size="8.00" fill="#4d4d4d">&lt;html&gt;</text>
</g>
<!-- table_thread_1 -->
<g id="node1" class="node">
<title>table_thread_1</title>
<polygon fill="#e6e2da" stroke="transparent" points="100,-68 28,-68 28,-32 100,-32 100,-68"/>
<text text-anchor="middle" x="64" y="-48.1" font-family="monospace" font-size="8.00">table(arrow)</text>
</g>
<!-- view_thread_1 -->
<g id="node2" class="node">
<title>view_thread_1</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-72 136,-72 136,-28 316,-28 316,-72"/>
<text text-anchor="start" x="144" y="-61.6" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-52.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by:&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="144" y="-43.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;split_by:&#39;[&quot;Segment&quot;]&#39;</text>
<text text-anchor="start" x="144" y="-34.6" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table_thread_1&#45;&gt;view_thread_1 -->
<g id="edge1" class="edge">
<title>table_thread_1&#45;&gt;view_thread_1</title>
<path fill="none" stroke="#edebdf" d="M100.11,-50C108.52,-50 117.91,-50 127.66,-50"/>
<polygon fill="#edebdf" stroke="#edebdf" points="127.91,-52.8 135.91,-50 127.91,-47.2 127.91,-52.8"/>
</g>
<!-- viewer4 -->
<g id="node3" class="node">
<title>viewer4</title>
<polygon fill="white" stroke="#b3b3b3" points="566,-72 364,-72 364,-28 566,-28 566,-72"/>
<text text-anchor="start" x="372" y="-61.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="372" y="-52.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;heatmap&quot;</text>
<text text-anchor="start" x="372" y="-43.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="372" y="-34.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;column&#45;pivots=&#39;[&quot;Segment&quot;]&#39;&gt;</text>
</g>
<!-- view_thread_1&#45;&gt;viewer4 -->
<g id="edge2" class="edge">
<title>view_thread_1&#45;&gt;viewer4</title>
<path fill="none" stroke="#d1a043" stroke-width="2" stroke-dasharray="5,2" d="M316.26,-50C331.75,-50 347.94,-50 363.69,-50"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,97 @@
digraph G {
bgcolor=transparent
graph [rankdir="LR" fontname="helvetica" labeljust="l"]
node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white];
edge [color="#EDEBDF" arrowsize=0.8]
subgraph cluster_11 {
label="\lPython Server";
fontcolor=gray30
margin=10
color=none
subgraph cluster_thread_2 {
graph [
label="\lPerspectiveManager - Thread 2";
style=filled
fillcolor="#91A4A8"
color=none
fontcolor="#EDEBDF"
fontsize=10
margin=10
]
table_thread_2 [
label="table(df)"
width=1
color=none
fillcolor="#E6E2DA"
]
view_thread_2 [
label="view({\l group_by: [\"State\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table_thread_2 -> view_thread_2;
}
}
subgraph cluster_browser {
graph [
label="\lBrowser";
color="#CADEE1";
margin=10
style=filled;
fontcolor=gray30
]
subgraph cluster_webworker2 {
graph [
label="\lWebWorker 2";
style=filled
margin=10
fillcolor="#2D4C68"
color=none
fontcolor="#EDEBDF"
fontsize=10
]
table12 [
label="table(...)"
width=1
color=none
fillcolor="#E6E2DA"
]
view12 [
label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l"
width=2.5
color=none
fillcolor="#EDEBDF"
]
table12 -> {view12} [color="#E6E2DA"];
}
view_thread_2 -> table12 [penwidth=2 style=dashed arrowhead=none color="#D1A043"];
subgraph cluster_41 {
graph [
label="\l<html>";
color=none
fillcolor=white
fontcolor=gray30
fontsize=10
fontname="monospace" fontsize=8 color=none
]
viewer5 [
label = "<perspective-viewer\l view=\"heatmap\"\l row-pivots='[\"State\"]\l column-pivots='[\"Segment\"]'>\l"
width=2.8
];
view12 -> viewer5 [penwidth=2 style=dashed arrowhead=none color="#666"];
}
}
}
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 5.0.0 (20220707.1540)
-->
<!-- Title: G Pages: 1 -->
<svg width="926pt" height="178pt"
viewBox="0.00 0.00 926.00 178.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 174)">
<title>G</title>
<g id="clust1" class="cluster">
<title>cluster_11</title>
<polygon fill="transparent" stroke="transparent" points="8,-12 8,-158 336,-158 336,-12 8,-12"/>
<text text-anchor="middle" x="59" y="-126.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Python Server</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_thread_2</title>
<polygon fill="#91a4a8" stroke="transparent" points="18,-22 18,-109 326,-109 326,-22 18,-22"/>
<text text-anchor="middle" x="94.5" y="-85" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">PerspectiveManager &#45; Thread 2</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_browser</title>
<polygon fill="#cadee1" stroke="#cadee1" points="344,-8 344,-162 910,-162 910,-8 344,-8"/>
<text text-anchor="middle" x="377" y="-130.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Browser</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_webworker2</title>
<polygon fill="#2d4c68" stroke="transparent" points="354,-18 354,-113 662,-113 662,-18 354,-18"/>
<text text-anchor="middle" x="391.5" y="-89" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">WebWorker 2</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_41</title>
<polygon fill="white" stroke="transparent" points="678,-18 678,-108 900,-108 900,-18 678,-18"/>
<text text-anchor="middle" x="700" y="-88.6" font-family="monospace" font-size="8.00" fill="#4d4d4d">&lt;html&gt;</text>
</g>
<!-- table_thread_2 -->
<g id="node1" class="node">
<title>table_thread_2</title>
<polygon fill="#e6e2da" stroke="transparent" points="100,-68 28,-68 28,-32 100,-32 100,-68"/>
<text text-anchor="middle" x="64" y="-48.1" font-family="monospace" font-size="8.00">table(df)</text>
</g>
<!-- view_thread_2 -->
<g id="node2" class="node">
<title>view_thread_2</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-68 136,-68 136,-32 316,-32 316,-68"/>
<text text-anchor="start" x="144" y="-57.1" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-48.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;State&quot;]]</text>
<text text-anchor="start" x="144" y="-39.1" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table_thread_2&#45;&gt;view_thread_2 -->
<g id="edge1" class="edge">
<title>table_thread_2&#45;&gt;view_thread_2</title>
<path fill="none" stroke="#edebdf" d="M100.11,-50C108.52,-50 117.91,-50 127.66,-50"/>
<polygon fill="#edebdf" stroke="#edebdf" points="127.91,-52.8 135.91,-50 127.91,-47.2 127.91,-52.8"/>
</g>
<!-- table12 -->
<g id="node3" class="node">
<title>table12</title>
<polygon fill="#e6e2da" stroke="transparent" points="436,-68 364,-68 364,-32 436,-32 436,-68"/>
<text text-anchor="middle" x="400" y="-48.1" font-family="monospace" font-size="8.00">table(...)</text>
</g>
<!-- view_thread_2&#45;&gt;table12 -->
<g id="edge3" class="edge">
<title>view_thread_2&#45;&gt;table12</title>
<path fill="none" stroke="#d1a043" stroke-width="2" stroke-dasharray="5,2" d="M316.02,-50C332.91,-50 349.64,-50 363.61,-50"/>
</g>
<!-- view12 -->
<g id="node4" class="node">
<title>view12</title>
<polygon fill="#edebdf" stroke="transparent" points="652,-72 472,-72 472,-28 652,-28 652,-72"/>
<text text-anchor="start" x="480" y="-61.6" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="480" y="-52.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;Category&quot;]</text>
<text text-anchor="start" x="480" y="-43.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;filter: [[&quot;State&quot;,&quot;==&quot;,&quot;Texas&quot;]]</text>
<text text-anchor="start" x="480" y="-34.6" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table12&#45;&gt;view12 -->
<g id="edge2" class="edge">
<title>table12&#45;&gt;view12</title>
<path fill="none" stroke="#e6e2da" d="M436.11,-50C444.52,-50 453.91,-50 463.66,-50"/>
<polygon fill="#e6e2da" stroke="#e6e2da" points="463.91,-52.8 471.91,-50 463.91,-47.2 463.91,-52.8"/>
</g>
<!-- viewer5 -->
<g id="node5" class="node">
<title>viewer5</title>
<polygon fill="white" stroke="#b3b3b3" points="890,-72 688,-72 688,-28 890,-28 890,-72"/>
<text text-anchor="start" x="696" y="-61.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-52.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;heatmap&quot;</text>
<text text-anchor="start" x="696" y="-43.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="696" y="-34.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;column&#45;pivots=&#39;[&quot;Segment&quot;]&#39;&gt;</text>
</g>
<!-- view12&#45;&gt;viewer5 -->
<g id="edge4" class="edge">
<title>view12&#45;&gt;viewer5</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M652.25,-50C663.9,-50 675.91,-50 687.73,-50"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 5.0.0 (20220707.1540)
-->
<!-- Title: G Pages: 1 -->
<svg width="926pt" height="499pt"
viewBox="0.00 0.00 926.00 499.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 495)">
<title>G</title>
<g id="clust1" class="cluster">
<title>cluster_11</title>
<polygon fill="transparent" stroke="transparent" points="8,-9 8,-314 336,-314 336,-9 8,-9"/>
<text text-anchor="middle" x="59" y="-282.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Python Server</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_thread_2</title>
<polygon fill="#91a4a8" stroke="transparent" points="18,-124 18,-265 326,-265 326,-124 18,-124"/>
<text text-anchor="middle" x="94.5" y="-241" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">PerspectiveManager &#45; Thread 2</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_thread_1</title>
<polygon fill="#91a4a8" stroke="transparent" points="18,-19 18,-114 326,-114 326,-19 18,-19"/>
<text text-anchor="middle" x="94.5" y="-90" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">PerspectiveManager &#45; Thread 1</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_browser</title>
<polygon fill="#cadee1" stroke="#cadee1" points="344,-8 344,-483 910,-483 910,-8 344,-8"/>
<text text-anchor="middle" x="377" y="-451.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#4d4d4d">Browser</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_2</title>
<polygon fill="#2d4c68" stroke="transparent" points="354,-231 354,-434 662,-434 662,-231 354,-231"/>
<text text-anchor="middle" x="391.5" y="-410" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">WebWorker 1</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_webworker2</title>
<polygon fill="#2d4c68" stroke="transparent" points="354,-120 354,-215 662,-215 662,-120 354,-120"/>
<text text-anchor="middle" x="391.5" y="-191" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#edebdf">WebWorker 2</text>
</g>
<g id="clust9" class="cluster">
<title>cluster_41</title>
<polygon fill="white" stroke="transparent" points="678,-18 678,-434 900,-434 900,-18 678,-18"/>
<text text-anchor="middle" x="700" y="-414.6" font-family="monospace" font-size="8.00" fill="#4d4d4d">&lt;html&gt;</text>
</g>
<!-- table_thread_2 -->
<g id="node1" class="node">
<title>table_thread_2</title>
<polygon fill="#e6e2da" stroke="transparent" points="100,-197 28,-197 28,-161 100,-161 100,-197"/>
<text text-anchor="middle" x="64" y="-177.1" font-family="monospace" font-size="8.00">table(df)</text>
</g>
<!-- view_thread_2 -->
<g id="node2" class="node">
<title>view_thread_2</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-170 136,-170 136,-134 316,-134 316,-170"/>
<text text-anchor="start" x="144" y="-159.1" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-150.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;State&quot;]]</text>
<text text-anchor="start" x="144" y="-141.1" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table_thread_2&#45;&gt;view_thread_2 -->
<g id="edge2" class="edge">
<title>table_thread_2&#45;&gt;view_thread_2</title>
<path fill="none" stroke="#edebdf" d="M100.11,-173.07C108.61,-171.64 118.1,-170.04 127.95,-168.38"/>
<polygon fill="#edebdf" stroke="#edebdf" points="128.49,-171.13 135.91,-167.03 127.56,-165.6 128.49,-171.13"/>
</g>
<!-- view_thread_2_2 -->
<g id="node3" class="node">
<title>view_thread_2_2</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-224 136,-224 136,-188 316,-188 316,-224"/>
<text text-anchor="start" x="144" y="-213.1" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-204.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;City&quot;]]</text>
<text text-anchor="start" x="144" y="-195.1" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table_thread_2&#45;&gt;view_thread_2_2 -->
<g id="edge1" class="edge">
<title>table_thread_2&#45;&gt;view_thread_2_2</title>
<path fill="none" stroke="#edebdf" d="M100.11,-184.93C108.61,-186.36 118.1,-187.96 127.95,-189.62"/>
<polygon fill="#edebdf" stroke="#edebdf" points="127.56,-192.4 135.91,-190.97 128.49,-186.87 127.56,-192.4"/>
</g>
<!-- table12 -->
<g id="node11" class="node">
<title>table12</title>
<polygon fill="#e6e2da" stroke="transparent" points="436,-170 364,-170 364,-134 436,-134 436,-170"/>
<text text-anchor="middle" x="400" y="-150.1" font-family="monospace" font-size="8.00">table(...)</text>
</g>
<!-- view_thread_2&#45;&gt;table12 -->
<g id="edge8" class="edge">
<title>view_thread_2&#45;&gt;table12</title>
<path fill="none" stroke="#d1a043" stroke-width="2" stroke-dasharray="5,2" d="M316.02,-152C332.91,-152 349.64,-152 363.61,-152"/>
</g>
<!-- table_thread_1 -->
<g id="node4" class="node">
<title>table_thread_1</title>
<polygon fill="#e6e2da" stroke="transparent" points="100,-69 28,-69 28,-33 100,-33 100,-69"/>
<text text-anchor="middle" x="64" y="-49.1" font-family="monospace" font-size="8.00">table(arrow)</text>
</g>
<!-- view_thread_1 -->
<g id="node5" class="node">
<title>view_thread_1</title>
<polygon fill="#edebdf" stroke="transparent" points="316,-73 136,-73 136,-29 316,-29 316,-73"/>
<text text-anchor="start" x="144" y="-62.6" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="144" y="-53.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by:&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="144" y="-44.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;split_by:&#39;[&quot;Segment&quot;]&#39;</text>
<text text-anchor="start" x="144" y="-35.6" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table_thread_1&#45;&gt;view_thread_1 -->
<g id="edge3" class="edge">
<title>table_thread_1&#45;&gt;view_thread_1</title>
<path fill="none" stroke="#edebdf" d="M100.11,-51C108.52,-51 117.91,-51 127.66,-51"/>
<polygon fill="#edebdf" stroke="#edebdf" points="127.91,-53.8 135.91,-51 127.91,-48.2 127.91,-53.8"/>
</g>
<!-- viewer4 -->
<g id="node19" class="node">
<title>viewer4</title>
<polygon fill="white" stroke="#b3b3b3" points="890,-72 688,-72 688,-28 890,-28 890,-72"/>
<text text-anchor="start" x="696" y="-61.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-52.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;heatmap&quot;</text>
<text text-anchor="start" x="696" y="-43.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="696" y="-34.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;column&#45;pivots=&#39;[&quot;Segment&quot;]&#39;&gt;</text>
</g>
<!-- view_thread_1&#45;&gt;viewer4 -->
<g id="edge12" class="edge">
<title>view_thread_1&#45;&gt;viewer4</title>
<path fill="none" stroke="#d1a043" stroke-width="2" stroke-dasharray="5,2" d="M316.2,-50.84C417.45,-50.66 582.21,-50.37 687.8,-50.18"/>
</g>
<!-- table1 -->
<g id="node6" class="node">
<title>table1</title>
<polygon fill="#e6e2da" stroke="transparent" points="436,-364 364,-364 364,-328 436,-328 436,-364"/>
<text text-anchor="middle" x="400" y="-344.1" font-family="monospace" font-size="8.00">table(csv)</text>
</g>
<!-- view1 -->
<g id="node8" class="node">
<title>view1</title>
<polygon fill="#edebdf" stroke="transparent" points="652,-339 472,-339 472,-295 652,-295 652,-339"/>
<text text-anchor="start" x="480" y="-328.6" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="480" y="-319.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;Category&quot;]</text>
<text text-anchor="start" x="480" y="-310.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;filter: [[&quot;State&quot;,&quot;==&quot;,&quot;Texas&quot;]]</text>
<text text-anchor="start" x="480" y="-301.6" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table1&#45;&gt;view1 -->
<g id="edge4" class="edge">
<title>table1&#45;&gt;view1</title>
<path fill="none" stroke="#edebdf" d="M436.11,-339.64C444.61,-338.1 454.1,-336.38 463.95,-334.59"/>
<polygon fill="#edebdf" stroke="#edebdf" points="464.54,-337.33 471.91,-333.15 463.54,-331.82 464.54,-337.33"/>
</g>
<!-- view2 -->
<g id="node9" class="node">
<title>view2</title>
<polygon fill="#edebdf" stroke="transparent" points="652,-393 472,-393 472,-357 652,-357 652,-393"/>
<text text-anchor="start" x="480" y="-382.1" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="480" y="-373.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;Sub&#45;Category&quot;]</text>
<text text-anchor="start" x="480" y="-364.1" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table1&#45;&gt;view2 -->
<g id="edge5" class="edge">
<title>table1&#45;&gt;view2</title>
<path fill="none" stroke="#edebdf" d="M436.11,-352.36C444.61,-353.9 454.1,-355.62 463.95,-357.41"/>
<polygon fill="#edebdf" stroke="#edebdf" points="463.54,-360.18 471.91,-358.85 464.54,-354.67 463.54,-360.18"/>
</g>
<!-- table_remote_view -->
<g id="node7" class="node">
<title>table_remote_view</title>
<polygon fill="#e6e2da" stroke="transparent" points="436,-277 364,-277 364,-241 436,-241 436,-277"/>
<text text-anchor="middle" x="400" y="-257.1" font-family="monospace" font-size="8.00">table(json)</text>
</g>
<!-- view3 -->
<g id="node10" class="node">
<title>view3</title>
<polygon fill="#edebdf" stroke="transparent" points="652,-277 472,-277 472,-241 652,-241 652,-277"/>
<text text-anchor="start" x="480" y="-257.1" font-family="monospace" font-size="8.00">view()</text>
</g>
<!-- table_remote_view&#45;&gt;view3 -->
<g id="edge6" class="edge">
<title>table_remote_view&#45;&gt;view3</title>
<path fill="none" stroke="#edebdf" d="M436.11,-259C444.52,-259 453.91,-259 463.66,-259"/>
<polygon fill="#edebdf" stroke="#edebdf" points="463.91,-261.8 471.91,-259 463.91,-256.2 463.91,-261.8"/>
</g>
<!-- viewer1 -->
<g id="node13" class="node">
<title>viewer1</title>
<polygon fill="white" stroke="#b3b3b3" points="890,-344 688,-344 688,-300 890,-300 890,-344"/>
<text text-anchor="start" x="696" y="-333.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-324.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;Y Bar&quot;</text>
<text text-anchor="start" x="696" y="-315.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;Category&quot;]&#39;</text>
<text text-anchor="start" x="696" y="-306.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;filters=&#39;[[&quot;State&quot;,&quot;==&quot;,&quot;Texas&quot;]]&#39;&gt;</text>
</g>
<!-- view1&#45;&gt;viewer1 -->
<g id="edge9" class="edge">
<title>view1&#45;&gt;viewer1</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M652.25,-318.98C663.9,-319.24 675.91,-319.51 687.73,-319.77"/>
</g>
<!-- viewer2 -->
<g id="node14" class="node">
<title>viewer2</title>
<polygon fill="white" stroke="lightgrey" points="890,-398 688,-398 688,-362 890,-362 890,-398"/>
<text text-anchor="start" x="696" y="-387.1" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-378.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;xy_scatter&quot;</text>
<text text-anchor="start" x="696" y="-369.1" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;Sub&#45;Category&quot;]&#39;&gt;</text>
</g>
<!-- view2&#45;&gt;viewer2 -->
<g id="edge10" class="edge">
<title>view2&#45;&gt;viewer2</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M652.25,-376.98C663.9,-377.24 675.91,-377.51 687.73,-377.77"/>
</g>
<!-- viewer3 -->
<g id="node15" class="node">
<title>viewer3</title>
<polygon fill="white" stroke="#b3b3b3" points="890,-282 688,-282 688,-246 890,-246 890,-282"/>
<text text-anchor="start" x="696" y="-266.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-257.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;grid&quot;&gt;</text>
</g>
<!-- view3&#45;&gt;viewer3 -->
<g id="edge11" class="edge">
<title>view3&#45;&gt;viewer3</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M652.25,-260.98C663.9,-261.24 675.91,-261.51 687.73,-261.77"/>
</g>
<!-- view12 -->
<g id="node12" class="node">
<title>view12</title>
<polygon fill="#edebdf" stroke="transparent" points="652,-174 472,-174 472,-130 652,-130 652,-174"/>
<text text-anchor="start" x="480" y="-163.6" font-family="monospace" font-size="8.00">view({</text>
<text text-anchor="start" x="480" y="-154.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;group_by: [&quot;Category&quot;]</text>
<text text-anchor="start" x="480" y="-145.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;filter: [[&quot;State&quot;,&quot;==&quot;,&quot;Texas&quot;]]</text>
<text text-anchor="start" x="480" y="-136.6" font-family="monospace" font-size="8.00">})</text>
</g>
<!-- table12&#45;&gt;view12 -->
<g id="edge7" class="edge">
<title>table12&#45;&gt;view12</title>
<path fill="none" stroke="#e6e2da" d="M436.11,-152C444.52,-152 453.91,-152 463.66,-152"/>
<polygon fill="#e6e2da" stroke="#e6e2da" points="463.91,-154.8 471.91,-152 463.91,-149.2 463.91,-154.8"/>
</g>
<!-- viewer5 -->
<g id="node17" class="node">
<title>viewer5</title>
<polygon fill="white" stroke="#b3b3b3" points="890,-174 688,-174 688,-130 890,-130 890,-174"/>
<text text-anchor="start" x="696" y="-163.6" font-family="monospace" font-size="8.00">&lt;perspective&#45;viewer</text>
<text text-anchor="start" x="696" y="-154.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;view=&quot;heatmap&quot;</text>
<text text-anchor="start" x="696" y="-145.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;row&#45;pivots=&#39;[&quot;State&quot;]</text>
<text text-anchor="start" x="696" y="-136.6" font-family="monospace" font-size="8.00"> &#160;&#160;&#160;column&#45;pivots=&#39;[&quot;Segment&quot;]&#39;&gt;</text>
</g>
<!-- view12&#45;&gt;viewer5 -->
<g id="edge13" class="edge">
<title>view12&#45;&gt;viewer5</title>
<path fill="none" stroke="#000000" stroke-width="2" stroke-dasharray="5,2" d="M652.25,-152C663.9,-152 675.91,-152 687.73,-152"/>
</g>
<!-- viewerN -->
<!-- viewerZ -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,39 @@
# Client-only
<img src="./architecture.sub1.svg" />
_For static datasets, datasets provided by the user, and simple server-less and
read-only web applications._
In this design, Perspective is run as a client Browser WebAssembly library, the
dataset is downloaded entirely to the client and all calculations and UI
interactions are performed locally. Interactive performance is very good, using
WebAssembly engine for near-native runtime plus WebWorker isolation for parallel
rendering within the browser. Operations like scrolling and creating new views
are responsive. However, the entire dataset must be downloaded to the client.
Perspective is not a typical browser component, and datset sizes of 1gb+ in
Apache Arrow format will load fine with good interactive performance!
Horizontal scaling is a non-issue, since here is no concurrent state to scale,
and only uses client-side computation via WebAssembly client. Client-only
perspective can support as many concurrent users as can download the web
application itself. Once the data is loaded, no server connection is needed and
all operations occur in the client browser, imparting no additional runtime cost
on the server beyond initial load. This also means updates and edits are local
to the browser client and will be lost when the page is refreshed, unless
otherwise persisted by your application.
As the client-only design starts with creating a client-side Perspective
`Table`, data can be provided by any standard web service in any Perspective
compatible format (JSON, CSV or Apache Arrow).
## Javascript client
```javascript
const worker = await perspective.worker();
const table = await worker.table(csv);
const viewer = document.createElement("perspective-viewer");
document.body.appendChild(viewer);
await viewer.load(table);
```
@@ -0,0 +1,58 @@
# Client/Server replicated
<img src="./architecture.sub2.svg" />
_For medium-sized, real-time, synchronized and/or editable data sets with many
concurrent users._
The dataset is instantiated in-memory with a Python or Node.js Perspective
server, and web applications create duplicates of these tables in a local
WebAssembly client in the browser, synchonized efficiently to the server via
Apache Arrow. This design scales well with additional concurrent users, as
browsers only need to download the initial data set and subsequent update
deltas, while operations like scrolling, pivots, sorting, etc. are performed on
the client.
Python servers can make especially good use of additional threads, as
Perspective will release the GIL for almost all operations. Interactive
performance on the client is very good and identical to client-only
architecture. Updates and edits are seamlessly synchonized across clients via
their virtual server counterparts using websockets and Apache Arrow.
## Python and Tornado server
```python
from perspective import Server, PerspectiveTornadoHandler
server = Server()
client = server.new_local_client()
client.table(csv, name="my_table")
routes = [(
r"/websocket",
perspective.handlers.tornado.PerspectiveTornadoHandler,
{"perspective_server": server},
)]
app = tornado.web.Application(routes)
app.listen(8080)
loop = tornado.ioloop.IOLoop.current()
loop.start()
```
## Javascript client
Perspective's websocket client interfaces with the Python server, then
_replicates_ the server-side Table.
```javascript
const websocket = await perspective.websocket("ws://localhost:8080");
const server_table = await websocket.open_table("my_table");
const server_view = await server_table.view();
const worker = await perspective.worker();
const client_table = await worker.table(server_view);
const viewer = document.createElement("perspective-viewer");
document.body.appendChild(viewer);
await viewer.load(client_table);
```
@@ -0,0 +1,33 @@
# Server-only
<img src="./architecture.sub3.svg" />
_For extremely large datasets with a small number of concurrent users._
The dataset is instantiated in-memory with a Python or Node.js server, and web
applications connect virtually. Has very good initial load performance, since no
data is downloaded. Group-by and other operations will run column-parallel if
configured.
But interactive performance is poor, as every user interaction must page the
server to render. Operations like scrolling are not as responsive and can be
impacted by network latency. Web applications must be "always connected" to the
server via WebSocket. Disconnecting will prevent any interaction, scrolling,
etc. of the UI. Does not use WebAssembly.
Each connected browser will impact server performance as long as the connection
is open, which in turn impacts interactive performance of every client. This
ultimately limits the horizontal scalabity of this architecture. Since each
client reads the perspective `Table` virtually, changes like edits and updates
are automatically reflected to all clients and persist across browser refresh.
Using the same Python server as the previous design, we can simply skip the
intermediate WebAssembly `Table` and pass the virtual table directly to `load()`
```javascript
const websocket = await perspective.websocket("ws://localhost:8080");
const server_table = await websocket.open_table("my_table");
const viewer = document.createElement("perspective-viewer");
document.body.appendChild(viewer);
await viewer.load(server_table);
```
+12
View File
@@ -0,0 +1,12 @@
# Join
`Client::join` creates a read-only `Table` by joining two source tables on a
shared key column. The `left` and `right` arguments can be `Table` objects or
string table names (as returned by `get_hosted_table_names()`). The resulting
table is _reactive_: whenever either source table is updated, the join is
automatically recomputed and any `View` derived from the joined table will
update accordingly.
Joined tables support the full `View` API — you can apply `group_by`,
`split_by`, `sort`, `filter`, `expressions`, and all other `View` operations on
the result, just as you would with any other `Table`.
+25
View File
@@ -0,0 +1,25 @@
# Join Types
`Client::join` supports three join types, specified via the `join_type` option.
The default is `"inner"`.
## Inner Join (default)
An inner join includes only rows where the key column exists in _both_ source
tables. Rows from either table that have no match in the other are excluded.
## Left Join
A left join includes all rows from the left table. For left rows that have no
match in the right table, right-side columns are filled with `null`.
## Outer Join
An outer join includes all rows from both tables. Unmatched rows on either side
have their missing columns filled with `null`.
| `join_type` | Left-only rows | Right-only rows |
| ----------- | -------------- | --------------- |
| `"inner"` | excluded | excluded |
| `"left"` | included | excluded |
| `"outer"` | included | included |
+35
View File
@@ -0,0 +1,35 @@
# Join Options
## `on` — Join Key Column
The `on` parameter specifies the column name used to match rows between the left
and right tables. This column must exist in the left table and, by default, must
also exist in the right table with the same name and compatible type.
The join key column becomes the index of the resulting table.
## `right_on` — Different Right Key Column
When the join key has a different name in the right table, use `right_on` to
specify the right table's column name. The left table's column name (`on`) is
used in the output schema; the right key column is excluded from the result.
The `on` and `right_on` columns must have compatible types. An error is thrown
if the types do not match.
## `join_type` — Join Type
Controls which rows are included in the result. See
[Join Types](./join_types.md) for details.
| Value | Behavior |
| ----------- | ----------------------------------------------------- |
| `"inner"` | Only rows with matching keys in both tables (default) |
| `"left"` | All left rows; unmatched right columns are `null` |
| `"outer"` | All rows from both tables; unmatched columns are `null` |
## `name` — Table Name
An optional name for the resulting joined table. If omitted, a random name is
generated. This name is used to identify the table in the server's hosted table
registry.
+46
View File
@@ -0,0 +1,46 @@
# Reactivity and Constraints
## Reactive Updates
Joined tables are fully reactive. When either source table receives an
`update()`, the join is automatically recomputed and any `View` created from the
joined table will reflect the new data. This includes:
- Updates that modify existing rows in either source table.
- New rows added to either source table that create new matches.
- Chained joins — if a joined table is itself used as input to another join,
updates propagate through the entire chain.
## Duplicate Keys
Like SQL, `join()` produces a cross-product for each matching key value. When
multiple rows in the left table share the same key, each is paired with every
matching row in the right table (and vice versa). The number of output rows for
a given key is `left_count × right_count`.
This behavior depends on whether the source tables are _indexed_:
- **Unindexed tables** (no `index` option) — rows are appended, so duplicate
keys accumulate naturally. Each `update()` appends new rows, which may
introduce additional duplicates.
- **Indexed tables** (`index` set to the join key) — each key appears at most
once per table, so the join produces at most one row per key. Updates replace
existing rows in-place rather than appending.
## Read-Only
Joined tables are read-only. Calling `update()`, `remove()`, `clear()`, or
`replace()` on a joined table will throw an error. Data can only change
indirectly, by updating the source tables.
## Column Name Conflicts
The left and right tables must not have overlapping column names (other than the
join key). If a non-key column name appears in both tables, `join()` throws an
error. Rename columns in your source data or use `View` expressions to avoid
conflicts.
## Source Table Deletion
A source table cannot be deleted while a joined table depends on it. You must
delete the joined table first, then delete the source tables.
+77
View File
@@ -0,0 +1,77 @@
# What is `perspective-python`
Perspective for Python uses the exact same C++ data engine used by the
[WebAssembly version](https://docs.rs/perspective-js/latest/perspective_js/) and
[Rust version](https://docs.rs/crate/perspective/latest). The library consists
of many of the same abstractions and API as in JavaScript, as well as
Python-specific data loading support for [NumPy](https://numpy.org/),
[Pandas](https://pandas.pydata.org/) (and
[Apache Arrow](https://arrow.apache.org/), as in JavaScript).
Additionally, `perspective-python` provides a session manager suitable for
integration into server systems such as
[Tornado websockets](https://www.tornadoweb.org/en/stable/websocket.html),
[AIOHTTP](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets), or
[Starlette](https://www.starlette.io/websockets/)/[FastAPI](https://fastapi.tiangolo.com/advanced/websockets/),
which allows fully _virtual_ Perspective tables to be interacted with by
multiple `<perspective-viewer>` in a web browser. You can also interact with a
Perspective table from python clients, and to that end client libraries are
implemented for both Tornado and AIOHTTP.
## Example
A simple example which loads an [Apache Arrow](https://arrow.apache.org/) and
computes a "Group By" operation, returning a new Arrow.
```python
from perspective import Server
client = Server().new_local_client()
table = client.table(arrow_bytes_data)
view = table.view(group_by = ["CounterParty", "Security"])
arrow = view.to_arrow()
```
[More Examples](https://github.com/perspective-dev/perspective/tree/master/examples)
are available on GitHub.
## What's included
The `perspective` module exports several tools:
- `Server` the constructor for a new instance of the Perspective data engine.
- The `perspective.widget` module exports `PerspectiveWidget`, the JupyterLab
widget for interactive visualization in a notebook cell.
- The `perspective.handlers` modules exports web frameworks handlers that
interface with a `perspective-client` in JavaScript.
- `perspective.handlers.tornado.PerspectiveTornadoHandler` for
[Tornado](https://www.tornadoweb.org/)
- `perspective.handlers.starlette.PerspectiveStarletteHandler` for
[Starlette](https://www.starlette.io/) and
[FastAPI](https://fastapi.tiangolo.com)
- `perspective.handlers.aiohttp.PerspectiveAIOHTTPHandler` for
[AIOHTTP](https://docs.aiohttp.org),
### Virtual UI server
As `<perspective-viewer>` or any other Perspective `Client` will only consume
the data necessary to render the current screen (or whatever else was requested
via the API), this runtime mode allows large datasets without the need to copy
them entirely to the Browser, at the expense of network latency on UI
interaction/API calls.
### Jupyterlab
`PerspectiveWidget` is a JupyterLab widget that implements the same API as
`<perspective-viewer>`, allows running such a viewer in
[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or
client (via WebAssembly) mode. `PerspectiveWidget` is compatible with Jupyterlab
3 and Jupyter Notebook 6 via a
[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions).
To use it, simply install `perspective-python` and the extensions should be
available.
`perspective-python`'s JupyterLab extension also provides convenient builtin
viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with
this extension and choose the appropriate `Perpective` option from the context
menu.
+14
View File
@@ -0,0 +1,14 @@
# Table
`Table` is Perspective's columnar data frame, analogous to a Pandas `DataFrame`
or Apache Arrow, supporting append & in-place updates, removal by index, and
update notifications.
A `Table` contains columns, each of which have a unique name, are strongly and
consistently typed, and contains rows of data conforming to the column's type.
Each column in a `Table` must have the same number of rows, though not every row
must contain data; null-values are used to indicate missing values in the
dataset. The schema of a `Table` is _immutable after creation_, which means the
column names and data types cannot be changed after the `Table` has been
created. Columns cannot be added or deleted after creation either, but a `View`
can be used to select an arbitrary set of columns from the `Table`.
@@ -0,0 +1,23 @@
# `Table::clear` and `Table::replace`
Calling `Table::clear` will remove all data from the underlying `Table`. Calling
`Table::replace` with new data will clear the `Table`, and update it with a new
dataset that conforms to Perspective's data types and the existing schema on the
`Table`.
<div class="javascript">
```javascript
table.clear();
table.replace(json);
```
</div>
<div class="python">
```python
table.clear()
table.replace(df)
```
</div>
@@ -0,0 +1,47 @@
# Construct a Table
Examples of constructing an empty `Table` from a schema.
<div class="javascript">
JavaScript:
```javascript
var schema = {
x: "integer",
y: "string",
z: "boolean",
};
const table2 = await worker.table(schema);
```
</div>
<div class="python">
Python:
```python
from datetime import date, datetime
schema = {
"x": "integer",
"y": "string",
"z": "boolean",
}
table2 = perspective.table(schema)
```
</div>
<div class="rust">
Rust:
```rust
let data = TableData::Schema(vec![(" a".to_string(), ColumnType::FLOAT)]);
let options = TableInitOptions::default();
let table = client.table(data.into(), options).await?;
```
</div>
+87
View File
@@ -0,0 +1,87 @@
# Loading data
A `Table` may also be created-or-updated by data in CSV,
[Apache Arrow](https://arrow.apache.org/), JSON row-oriented or JSON
column-oriented formats. In addition to these, `perspective-python` additionally
supports `pyarrow.Table`, `polars.DataFrame` and `pandas.DataFrame` objects
directly. These formats are otherwise identical to the built-in formats and
don't exhibit any additional support or type-awareness; e.g., `pandas.DataFrame`
support is _just_ `pyarrow.Table.from_pandas` piped into Perspective's Arrow
reader.
`Client::table` and `Table::update` perform _coercion_ on their input for all
input formats _except_ Arrow (which comes with its own schema and has no need
for coercion). `"date"` and `"datetime"` column types do not have native JSON
representations, so these column types _cannot_ be inferred from JSON input.
Instead, for columns of these types for JSON input, a `Table` must first be
constructed with a _schema_. Next, call `Table::update` with the JSON input -
Perspective's JSON reader may _coerce_ a `date` or `datetime` from these native
JSON types:
- `integer` as milliseconds-since-epoch.
- `string` as a any of Perspective's built-in date format formats.
- JavaScript `Date` and Python `datetime.date` and `datetime.datetime` are _not_
supported directly. However, in JavaScript `Date` types are automatically
coerced to correct `integer` timestamps by default when converted to JSON.
## Apache Arrow
The most efficient way to load data into Perspective, encoded as
[Apache Arrow IPC format](https://arrow.apache.org/docs/python/ipc.html). In
JavaScript:
```javascript
const resp = await fetch(
"https://cdn.jsdelivr.net/npm/superstore-arrow/superstore.lz4.arrow",
);
const arrow = await resp.arrayBuffer();
```
Apache Arrow input do not support type coercion, preferring Arrow's internal
self-describing schema.
## CSV
Perspective relies on Apache Arrow's CSV parser, and as such uses mostly the
same column-type inference logic as Arrow itself would use for parsing CSV.
## Row Oriented JSON
Row-oriented JSON is in the form of a list of objects. Each object in the list
corresponds to a row in the table. For example:
```json
[
{ "a": 86, "b": false, "c": "words" },
{ "a": 0, "b": true, "c": "" },
{ "a": 12345, "b": false, "c": "here" }
]
```
## Column Oriented JSON
Column-Oriented JSON comes in the form of an object of lists. Each key of the
object is a column name, and each element of the list is the corresponding value
in the row.
```json
{
"a": [86, 0, 12345],
"b": [false, true, false],
"c": ["words", "", "here"]
}
```
## NDJSON
[NDJSON](https://github.com/ndjson/ndjson-spec) (sometimes also referred to as
JSONL) is a streaming-friendly format where each line is a valid JSON object,
separated by newlines. It is commonly used in data streaming and messaging
queues.
```json
{ "a": 86, "b": false, "c": "words" }
{ "a": 0, "b": true, "c": "" }
{ "a": 12345, "b": false, "c": "here" }
```
+59
View File
@@ -0,0 +1,59 @@
## Index and Limit
<div class="warning">`limit` cannot be used in conjunction with `index`.</div>
Initializing a `Table` with an `index` tells Perspective to treat a column as
the primary key, allowing in-place updates of rows. Only a single column (of any
type) can be used as an `index`. Indexed `Table` instances allow:
- In-place _updates_ whenever a new row shares an `index` values with an
existing row
- _Partial updates_ when a data batch omits some column.
- _Removes_ to delete a row by `index`.
To create an indexed `Table`, provide the `index` property with a string column
name to be used as an index:
<div class="javascript">
JavaScript:
```javascript
const indexed_table = await perspective.table(data, { index: "a" });
```
</div>
<div class="python">
Python
```python
indexed_table = perspective.Table(data, index="a");
```
</div>
Initializing a `Table` with a `limit` sets the total number of rows the `Table`
is allowed to have. When the `Table` is updated, and the resulting size of the
`Table` would exceed its `limit`, rows that exceed `limit` overwrite the oldest
rows in the `Table`. To create a `Table` with a `limit`, provide the `limit`
property with an integer indicating the maximum rows:
<div class="javascript">
JavaScript:
```javascript
const limit_table = await perspective.table(data, { limit: 1000 });
```
</div>
<div class="python">
Python:
```python
limit_table = perspective.Table(data, limit=1000);
```
</div>
+41
View File
@@ -0,0 +1,41 @@
# Schema and column types
The mapping of a `Table`'s column names to data types is referred to as a
`schema`. Each column has a unique name and a single data type, one of
- `float`
- `integer`
- `boolean`
- `date`
- `datetime`
- `string`
A `Table` schema is fixed at construction, either by explicitly passing a schema
dictionary to the `Client::table` method, or by passing _data_ to this method
from which the schema is _inferred_ (if CSV or JSON format) or inherited (if
Arrow).
## Type inference
When passing CSV or JSON data to the `Client::table` constructor, the type of
each column is inferred automatically. In some cases, the inference algorithm
may not return exactly what you'd like. For example, a column may be interpreted
as a `datetime` when you intended it to be a `string`, or a column may have no
values at all (yet), as it will be updated with values from a real-time data
source later on. In these cases, create a `table()` with a _schema_.
Once the `Table` has been created, further `Table::update` calls will perform
limited type _coercion_ based on the schema. While _coercion_ works similarly to
_inference_, in that input data may be parsed based on the expected column type,
`Table::update` will not _change_ the column's type further. For example, a
number literal `1234` would be _inferred_ as an `"integer"`, but _in the context
of an `Table::update` call on a known `"string"` column_, this will be parsed as
the _string_ `"1234"`.
## `date` and `datetime` inference
Various string representations of `date` and `datetime` format columns can be
_inferred_ as well _coerced_ from strings if they match one of Perspective's
internal known datetime parsing formats, for example
[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) (which is also the format
Perspective will _output_ these types for CSV).
@@ -0,0 +1,88 @@
# `Table::update` and `Table::remove`
Once a `Table` has been created, it can be updated with new data conforming to
the `Table`'s schema. `Table::update` supports the same data formats as
`Client::table`, minus _schema_.
<div class="javascript">
```javascript
const schema = {
a: "integer",
b: "float",
};
const table = await perspective.table(schema);
table.update(new_data);
```
</div>
<div class="python">
```python
schema = {"a": "integer", "b": "float"}
table = perspective.Table(schema)
table.update(new_data)
```
</div>
Without an `index` set, calls to `update()` _append_ new data to the end of the
`Table`. Otherwise, Perspective allows
[_partial updates_ (in-place)](#index-and-limit) using the `index` to determine
which rows to update:
<div class="javascript">
```javascript
indexed_table.update({ id: [1, 4], name: ["x", "y"] });
```
</div>
<div class="python">
```python
indexed_table.update({"id": [1, 4], "name": ["x", "y"]})
```
</div>
Any value on a `Client::table` can be unset using the value `null` in JSON or
Arrow input formats. Values may be unset on construction, as any `null` in the
dataset will be treated as an unset value. `Table::update` calls do not need to
provide _all columns_ in the `Table`'s schema; missing columns will be omitted
from the `Table`'s updated rows.
<div class="javascript">
```javascript
table.update([{ x: 3, y: null }]); // `z` missing
```
</div>
<div class="python">
```python
table.update([{"x": 3, "y": None}]) # `z` missing
```
</div>
Rows can also be removed from an indexed `Table`, by calling `Table::remove`
with an array of index values:
<div class="javascript">
```javascript
indexed_table.remove([1, 4]);
```
</div>
<div class="python">
```python
indexed_table.remove([1, 4])
```
</div>
+70
View File
@@ -0,0 +1,70 @@
# View
The [`View`] struct is Perspective's query and serialization interface. It
represents a query on the `Table`'s dataset and is always created from an
existing `Table` instance via the [`Table::view`] method.
[`View`]s are immutable with respect to the arguments provided to the
[`Table::view`] method; to change these parameters, you must create a new
[`View`] on the same [`Table`]. However, each [`View`] is _live_ with respect to
the [`Table`]'s data, and will (within a conflation window) update with the
latest state as its parent [`Table`] updates, including incrementally
recalculating all aggregates, pivots, filters, etc. [`View`] query parameters
are composable, in that each parameter works independently _and_ in conjunction
with each other, and there is no limit to the number of pivots, filters, etc.
which can be applied.
<div class="javascript">
<div class="warning">
The examples in this module are in JavaScript. See <a href="https://docs.rs/crate/perspective/latest"><code>perspective</code></a> docs for the Rust API.
</div>
</div>
<div class="python">
<div class="warning">
The examples in this module are in Python. See <a href="https://docs.rs/crate/perspective/latest"><code>perspective</code></a> docs for the Rust API.
</div>
</div>
# Examples
<div class="javascript">
```javascript
const table = await perspective.table({
id: [1, 2, 3, 4],
name: ["a", "b", "c", "d"],
});
const view = await table.view({ columns: ["name"] });
const json = await view.to_json();
await view.delete();
```
</div>
<div class="python">
```python
table = perspective.Table({
"id": [1, 2, 3, 4],
"name": ["a", "b", "c", "d"]
});
view = table.view(columns=["name"])
arrow = view.to_arrow()
view.delete()
```
</div>
<div class="rust">
```rust
let opts = TableInitOptions::default();
let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
let table = client.table(data, opts).await?;
let view = table.view(None).await?;
let arrow = view.to_arrow().await?;
view.delete().await?;
```
</div>
+231
View File
@@ -0,0 +1,231 @@
# Advanced View Operations
Beyond the standard query configuration, `View` provides additional methods for
interacting with hierarchical results and introspecting data.
## Tree Hierarchy Operations
When a `View` has `group_by` applied, the results form a tree hierarchy.
Perspective provides methods to control which levels of the tree are expanded or
collapsed:
<div class="javascript">
```javascript
const view = await table.view({ group_by: ["Region", "Country", "City"] });
// Collapse the tree at row index 5
await view.collapse(5);
// Expand the tree at row index 5
await view.expand(5);
// Set the expansion depth (0 = fully collapsed, 1 = first level, etc.)
await view.set_depth(1);
```
</div>
<div class="python">
Using the sync API
```python
view = table.view(group_by=["Region", "Country", "City"])
view.collapse(5)
view.expand(5)
view.set_depth(1)
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
group_by: Some(vec!["Region".into(), "Country".into(), "City".into()]),
..ViewConfigUpdate::default()
})).await?;
view.collapse(5).await?;
view.expand(5).await?;
view.set_depth(1).await?;
```
</div>
<span class="warning">Perspective's built-in engine is lazy — aggregates for
collapsed rows are not recalculated when the underlying `Table` is updated.
Updates are only computed for rows that are currently visible (expanded). When a
collapsed row is later expanded, its aggregates are calculated at that
point.</span>
## Column Range Queries
`View::get_min_max` returns the minimum and maximum values for a given column,
which is useful for setting up scales in custom visualizations:
<div class="javascript">
```javascript
const [min, max] = await view.get_min_max("Sales");
```
</div>
<div class="python">
```python
min_val, max_val = view.get_min_max("Sales")
```
</div>
## Expression Validation
Before creating a `View` with expressions, you can validate them against the
table's schema using `Table::validate_expressions`. This returns information
about which expressions are valid and their inferred types:
<div class="javascript">
```javascript
const result = await table.validate_expressions({
expr1: '"Sales" + "Profit"',
expr2: "invalid_column + 1",
});
// result.expression_schema contains valid expressions and their types
// result.errors contains invalid expressions and error messages
```
</div>
<div class="python">
```python
result = table.validate_expressions(['"Sales" + "Profit"', 'invalid + 1'])
```
</div>
## View Dimensions
`View::dimensions` returns the number of rows and columns in the current view,
including information about group-by header rows:
<div class="javascript">
```javascript
const dims = await view.dimensions();
// { num_view_rows, num_view_columns, num_table_rows, num_table_columns, ... }
```
</div>
<div class="python">
```python
dims = view.dimensions()
```
</div>
## View Configuration Introspection
`View::get_config` returns the full configuration used to create the view:
<div class="javascript">
```javascript
const config = await view.get_config();
// { group_by: [...], split_by: [...], sort: [...], filter: [...], ... }
```
</div>
<div class="python">
```python
config = view.get_config()
```
</div>
## Update Callbacks
Register a callback to be notified whenever the underlying `Table` is updated
and the `View` has been recalculated:
<div class="javascript">
```javascript
view.on_update(
(updated) => {
console.log("View updated", updated.port_id);
},
{ mode: "row" },
);
// Later, remove the callback
view.remove_update(callback);
```
</div>
<div class="python">
```python
def on_update(port_id, delta):
print("View updated", port_id)
view.on_update(on_update, mode="row")
view.remove_update(on_update)
```
</div>
When `mode` is set to `"row"`, the callback receives a delta of only the rows
that changed (as Apache Arrow), which is useful for efficiently synchronizing
tables across clients.
## Flattening a View into a Table
In Javascript, a [`Table`] can be constructed on a [`Table::view`] instance,
which will return a new [`Table`] based on the [`Table::view`]'s dataset, and
all future updates that affect the [`Table::view`] will be forwarded to the new
[`Table`]. This is particularly useful for implementing a
[Client/Server Replicated](server.md#clientserver-replicated) design, by
serializing the `View` to an arrow and setting up an `on_update` callback.
<div class="javascript">
```javascript
const worker1 = perspective.worker();
const table = await worker.table(data);
const view = await table.view({ filter: [["State", "==", "Texas"]] });
const table2 = await worker.table(view);
table.update([{ State: "Texas", City: "Austin" }]);
```
</div>
<div class="python">
```python
table = perspective.Table(data);
view = table.view(filter=[["State", "==", "Texas"]])
table2 = perspective.Table(view.to_arrow());
def updater(port, delta):
table2.update(delta)
view.on_update(updater, mode="Row")
table.update([{"State": "Texas", "City": "Austin"}])
```
</div>
<div class="rust">
```rust
let opts = TableInitOptions::default();
let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
let table = client.table(data, opts).await?;
let view = table.view(None).await?;
let table2 = client.table(TableData::View(view)).await?;
table.update(data).await?;
```
</div>
@@ -0,0 +1,149 @@
# Expressions
The `expressions` property specifies _new_ columns in Perspective that are
created using existing column values or arbitrary scalar values defined within
the expression. In `<perspective-viewer>`, expressions are added using the "New
Column" button in the side panel.
Expressions are strings parsed by Perspective's expression engine (based on
[ExprTK](https://github.com/ArashPartow/exprtk)). Column names are referenced by
wrapping them in double quotes, e.g. `"Sales"`:
<div class="javascript">
```javascript
const view = await table.view({
expressions: {
"Profit Ratio": '"Profit" / "Sales"',
},
});
```
</div>
<div class="python">
```python
view = table.view(expressions={'Profit Ratio': '"Profit" / "Sales"'})
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
expressions: Some(Expressions([
("Profit Ratio", "\"Profit\" / \"Sales\"".into())
].into_iter().collect())),
..ViewConfigUpdate::default()
})).await?;
```
</div>
## Type Conversion and Coercion
Perspective expressions are strongly typed — each column and literal has a fixed
type, and most operators require matching types on both sides. To work across
types, use the conversion functions:
| Function | Description |
| --------------- | ------------------------------------------------------------ |
| `to_string(x)` | Convert any type to string |
| `to_integer(x)` | Convert to integer (null if not parsable) |
| `to_float(x)` | Convert to float (null if not parsable) |
| `to_boolean(x)` | Convert to boolean (truthy/falsy) |
| `integer(x)` | Alias for `to_integer(x)` |
| `float(x)` | Alias for `to_float(x)` |
| `datetime(x)` | Construct a datetime from a POSIX timestamp (ms since epoch) |
| `date(y, m, d)` | Construct a date from year, month, day |
### How coercion works
Perspective does not implicitly coerce types. For example, you cannot directly
add an `integer` to a `float` — you must cast one side explicitly. Similarly,
`datetime` and `date` values are not numeric: to perform arithmetic on them, you
must first convert to a numeric representation, do the math, then convert back.
Internally, `datetime` values are stored as milliseconds since the Unix epoch
(1970-01-01T00:00:00Z). Converting a `datetime` to a `float` yields this
millisecond timestamp, and `datetime()` accepts a millisecond timestamp to
produce a `datetime`.
### Example: offsetting a datetime by 7 days
This expression takes a `"Shipped Date"` column, converts it to its
millisecond-epoch representation, adds 7 days worth of milliseconds (7 &times;
24 &times; 60 &times; 60 &times; 1000 = 604800000), and converts the result back
to a `datetime`:
```
// Due Date
datetime(float("Shipped Date") + 604800000)
```
## Operators
Standard arithmetic and comparison operators are supported:
| Operator | Description |
| -------------------------------- | ----------- |
| `+`, `-`, `*`, `/` | Arithmetic |
| `%` | Modulo |
| `==`, `!=`, `<`, `>`, `<=`, `>=` | Comparison |
| `and`, `or`, `not` | Logical |
| `if ... else ...` | Conditional |
## Numeric Functions
ExprTK provides a rich set of built-in numeric functions including `abs`,
`ceil`, `floor`, `round`, `exp`, `log`, `log10`, `sqrt`, `min`, `max`, `pow`,
`clamp`, `iclamp`, `inrange`, and trigonometric functions (`sin`, `cos`, `tan`,
`asin`, `acos`, `atan`).
## String Functions
| Function | Description |
| ------------------------------- | ------------------------------------------------------- |
| `concat(a, b, ...)` | Concatenate strings |
| `upper(s)` | Convert to uppercase |
| `lower(s)` | Convert to lowercase |
| `length(s)` | String length |
| `contains(s, substr)` | Whether `s` contains `substr` |
| `order(col, 'B', 'C', 'A')` | Custom sort order for a string column |
| `match(s, pattern)` | Regex partial match (returns boolean) |
| `match_all(s, pattern)` | Regex full match (returns boolean) |
| `search(s, pattern)` | First capturing group match |
| `indexof(s, pattern)` | Start index of first regex match |
| `substring(s, start, end)` | Substring from `start` (inclusive) to `end` (exclusive) |
| `replace(s, repl, pattern)` | Replace first regex match |
| `replace_all(s, repl, pattern)` | Replace all regex matches |
## Date/Datetime Functions
| Function | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `today()` | Current date |
| `now()` | Current datetime |
| `date(year, month, day)` | Construct a date |
| `datetime(timestamp_ms)` | Construct a datetime from a POSIX timestamp (ms since epoch) |
| `hour_of_day(dt)` | Hour component (0-23) |
| `day_of_week(dt)` | Day of the week as a string |
| `month_of_year(dt)` | Month of the year as a string |
| `bucket(dt, unit)` | Bucket datetime by unit: `'s'`, `'m'`, `'h'`, `'D'`, `'W'`, `'M'`, `'Y'` |
`bucket` also works on numeric columns: `bucket("Price", 10)` rounds values down
to the nearest multiple of 10.
## Other Functions
| Function | Description |
| ------------------------- | ----------------------------------------------------- |
| `is_null(x)` | Whether the value is null |
| `is_not_null(x)` | Whether the value is not null |
| `percent_of(a, b)` | `a` as a percentage of `b` |
| `inrange(low, val, high)` | Whether `val` is between `low` and `high` (inclusive) |
| `min(a, b, ...)` | Minimum of inputs |
| `max(a, b, ...)` | Maximum of inputs |
| `random()` | Random float between 0.0 and 1.0 |
| `col(name)` | Look up a column by string name at runtime |
| `vlookup(col, key)` | Look up a value in another column by row key |
@@ -0,0 +1,147 @@
# Grouping and Pivots
## Group By
A group by _groups_ the dataset by the unique values of each column used as a
group by - a close analogue in SQL to the `GROUP BY` statement. The underlying
dataset is aggregated to show the values belonging to each group, and a total
row is calculated for each group, showing the currently selected aggregated
value (e.g. `sum`) of the column. Group by are useful for hierarchies,
categorizing data and attributing values, i.e. showing the number of units sold
based on State and City. In Perspective, group by are represented as an array of
string column names to pivot, are applied in the order provided; For example, a
group by of `["State", "City", "Postal Code"]` shows the values for each Postal
Code, which are grouped by City, which are in turn grouped by State.
<div class="javascript">
```javascript
const view = await table.view({ group_by: ["a", "c"] });
```
</div>
<div class="python">
```python
view = table.view(group_by=["a", "c"])
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
group_by: Some(vec!["a".into(), "c".into()]),
..ViewConfigUpdate::default()
})).await?;
```
</div>
## Split By
A split by _splits_ the dataset by the unique values of each column used as a
split by. The underlying dataset is not aggregated, and a new column is created
for each unique value of the split by. Each newly created column contains the
parts of the dataset that correspond to the column header, i.e. a `View` that
has `["State"]` as its split by will have a new column for each state. In
Perspective, Split By are represented as an array of string column names to
pivot:
<div class="javascript">
```javascript
const view = await table.view({ split_by: ["a", "c"] });
```
</div>
<div class="python">
```python
view = table.view(split_by=["a", "c"])
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
split_by: Some(vec!["a".into(), "c".into()]),
..ViewConfigUpdate::default()
})).await?;
```
</div>
## Aggregates
Aggregates perform a calculation over an entire column, and are displayed when
one or more [Group By](#group-by) are applied to the `View`. Aggregates can be
specified by the user, or Perspective will use the following sensible default
aggregates based on column type:
- "sum" for `integer` and `float` columns
- "count" for all other columns
Perspective provides a selection of aggregate functions that can be applied to
columns in the `View` constructor using a dictionary of column name to aggregate
function name.
<div class="javascript">
```javascript
const view = await table.view({
aggregates: {
a: "avg",
b: "distinct count",
},
});
```
</div>
<div class="python">
```python
view = table.view(
aggregates={
"a": "avg",
"b": "distinct count"
}
)
```
</div>
<div class="rust">
```rust
use std::collections::HashMap;
let view = table.view(Some(ViewConfigUpdate {
aggregates: Some(HashMap::from([
("a".into(), "avg".into()),
("b".into(), "distinct count".into()),
])),
..ViewConfigUpdate::default()
})).await?;
```
</div>
The available aggregate functions depend on the column type:
**Numeric columns** (`integer`, `float`): `sum`, `abs sum`, `sum abs`,
`sum not null`, `any`, `avg`, `mean`, `count`, `distinct count`, `dominant`,
`first`, `last`, `last by index`, `high`, `low`, `max`, `min`,
`high minus low`, `last minus first`, `median`, `q1`, `q3`,
`pct sum parent`, `pct sum total`, `stddev`, `var`, `unique`,
`weighted mean`, `min by`, `max by`.
**String columns**: `count`, `any`, `distinct count`, `dominant`, `first`,
`last`, `last by index`, `join`, `median`, `q1`, `q3`, `unique`, `min by`,
`max by`.
**Date/Datetime columns**: `count`, `any`, `avg`, `distinct count`, `dominant`,
`first`, `last`, `last by index`, `high`, `low`, `max`, `min`, `median`,
`q1`, `q3`, `unique`.
**Boolean columns**: `count`, `any`, `distinct count`, `dominant`, `first`,
`last`, `last by index`, `unique`.
@@ -0,0 +1,138 @@
# Selection and Ordering
## Columns
The `columns` property specifies which columns should be included in the
`View`'s output. This allows users to show or hide a specific subset of columns,
as well as control the order in which columns appear to the user. This is
represented in Perspective as an array of string column names:
<div class="javascript">
```javascript
const view = await table.view({
columns: ["a"],
});
```
</div>
<div class="python">
```python
view = table.view(columns=["a"])
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
columns: Some(vec![Some("a".into())]),
..ViewConfigUpdate::default()
})).await?;
```
</div>
## Sort
The `sort` property specifies columns on which the query should be sorted,
analogous to `ORDER BY` in SQL. A column can be sorted regardless of its data
type, and sorts can be applied in ascending or descending order. Perspective
represents `sort` as an array of arrays, with the values of each inner array
being a string column name and a string sort direction. When `split_by` are
applied, the additional sort directions `"col asc"` and `"col desc"` will
determine the order of pivot column groups.
<div class="javascript">
```javascript
const view = await table.view({
sort: [["a", "asc"]],
});
```
</div>
<div class="python">
```python
view = table.view(sort=[["a", "asc"]])
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
sort: Some(vec![Sort("a".into(), SortDir::Asc)]),
..ViewConfigUpdate::default()
})).await?;
```
</div>
The available sort directions are:
| Direction | Description |
|---|---|
| `"asc"` | Ascending order |
| `"desc"` | Descending order |
| `"asc abs"` | Ascending by absolute value |
| `"desc abs"` | Descending by absolute value |
| `"col asc"` | Ascending order for pivot column groups (requires `split_by`) |
| `"col desc"` | Descending order for pivot column groups (requires `split_by`) |
| `"col asc abs"` | Ascending by absolute value for pivot column groups |
| `"col desc abs"` | Descending by absolute value for pivot column groups |
## Filter
The `filter` property specifies columns on which the query can be filtered,
returning rows that pass the specified filter condition. This is analogous to
the `WHERE` clause in SQL. There is no limit on the number of columns where
`filter` is applied, but the resulting dataset is one that passes all the filter
conditions, i.e. the filters are joined with an `AND` condition. The join
condition can be changed to `OR` via the `filter_op` property.
Perspective represents `filter` as an array of arrays, with the values of each
inner array being a string column name, a string filter operator, and a filter
operand in the type of the column:
<div class="javascript">
```javascript
const view = await table.view({
filter: [["a", "<", 100]],
});
```
</div>
<div class="python">
```python
view = table.view(filter=[["a", "<", 100]])
```
</div>
<div class="rust">
```rust
let view = table.view(Some(ViewConfigUpdate {
filter: Some(vec![Filter::new("a", "<", FilterTerm::Scalar(Scalar::Float(100.0)))]),
..ViewConfigUpdate::default()
})).await?;
```
</div>
The available filter operators depend on the column type:
**String columns**: `==`, `!=`, `>`, `>=`, `<`, `<=`, `begins with`,
`contains`, `ends with`, `in`, `not in`, `is not null`, `is null`.
**Numeric columns** (`integer`, `float`): `==`, `!=`, `>`, `>=`, `<`, `<=`,
`is not null`, `is null`.
**Boolean columns**: `==`, `is not null`, `is null`.
**Date/Datetime columns**: `==`, `!=`, `>`, `>=`, `<`, `<=`, `is not null`,
`is null`.
+50
View File
@@ -0,0 +1,50 @@
# Querying data
To query the table, create a [`Table::view`] on the table instance with an
optional configuration object. A [`Table`] can have as many [`View`]s associated
with it as you need - Perspective conserves memory by relying on a single
[`Table`] to power multiple [`View`]s concurrently:
<div class="javascript">
```javascript
const view = await table.view({
columns: ["Sales"],
aggregates: { Sales: "sum" },
group_by: ["Region", "Country"],
filter: [["Category", "in", ["Furniture", "Technology"]]],
});
```
</div>
<div class="python">
```python
view = table.view(
columns=["Sales"],
aggregates={"Sales": "sum"},
group_by=["Region", "Country"],
filter=[["Category", "in", ["Furniture", "Technology"]]]
)
```
</div>
<div class="rust">
```rust
use crate::config::*;
let view = table
.view(Some(ViewConfigUpdate {
columns: Some(vec![Some("Sales".into())]),
aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])),
group_by: Some(vec!["Region".into(), "Country".into()]),
filter: Some(vec![Filter::new("Category", "in", &[
"Furniture",
"Technology",
])]),
..ViewConfigUpdate::default()
}))
.await?;
```
</div>
+83
View File
@@ -0,0 +1,83 @@
# Virtual Servers
A Virtual Server allows Perspective to query external data sources (such as
DuckDB or ClickHouse) without loading the entire dataset into Perspective's
built-in data engine. Instead, Perspective translates its query operations
(group by, sort, filter, etc.) into queries the external data source can execute
natively, and only transfers the data needed for the current view.
The Virtual Server API works on any platform that has a Perspective Client —
including JavaScript (both Node.js and the browser via WebAssembly), Python, and
Rust. In the browser, this means a virtual server can front a WASM-based engine
like `@duckdb/duckdb-wasm`, giving `<perspective-viewer>` the ability to query a
database running entirely client-side without loading data into Perspective's
own engine.
This is useful when:
- The dataset is too large to fit in browser memory or a single process.
- Data already lives in a database and you want to avoid duplicating it.
- You want to leverage a database's native query optimizations.
- A WASM build of the data source is available in the browser (e.g.
`@duckdb/duckdb-wasm`) and you want to query it directly.
## How it works
A virtual server implements a handler interface that Perspective calls to
satisfy `Table` and `View` operations. The handler translates Perspective's view
configuration into the external system's query language (typically SQL),
executes the query, and returns the results as columnar data. Because the
handler speaks the standard Perspective Client protocol, it can run anywhere a
Client can — in-process, in a WebWorker, or on a remote server.
```
┌──────────────────────────────────────────────────┐
│ <perspective-viewer> │
└──┬───────────────────────────────────────────────┘
│ ┌──────────────────────────────────────────────────┐
└──►│ Perspective Virtual Server Handler │
└──┬───────────────────────────────────────────────┘
│ ┌──────────────────────────────────────────────────┐
└──►│ External DB (DuckDB, ClickHouse, …). │
└──────────────────────────────────────────────────┘
```
The viewer communicates with the virtual server handler the same way it would
with a regular Perspective server. The handler advertises its capabilities
(which operations it supports) via a _features_ object, and the viewer UI adapts
accordingly — disabling controls for unsupported operations.
## Built-in implementations
Perspective ships with virtual server implementations for:
- **DuckDB** — query DuckDB databases in-browser via WASM
([JavaScript](../how_to/javascript/virtual_server/duckdb.md)) or server-side
([Python](../how_to/python/virtual_server/duckdb.md)).
- **ClickHouse** — query a ClickHouse server from the browser
([JavaScript](../how_to/javascript/virtual_server/clickhouse.md)) or from
Python ([Python](../how_to/python/virtual_server/clickhouse.md)).
## Custom implementations
You can implement your own virtual server to connect Perspective to any data
source. See the language-specific guides:
- [JavaScript: Implementing a custom Virtual Server](../how_to/javascript/virtual_server/custom.md)
- [Python: Implementing a custom Virtual Server](../how_to/python/virtual_server/custom.md)
## Features declaration
The `get_features()` / `getFeatures()` method returns an object that tells
Perspective which query operations the virtual server supports. The viewer will
only show controls for supported operations:
| Field | Type | Description |
| ------------- | ------ | ----------------------------------------------------------- |
| `group_by` | `bool` | Whether group-by aggregation is supported |
| `split_by` | `bool` | Whether split-by (pivot) is supported |
| `sort` | `bool` | Whether sorting is supported |
| `expressions` | `bool` | Whether computed expressions are supported |
| `filter_ops` | `dict` | Map of column type to list of supported filter operators |
| `aggregates` | `dict` | Map of column type to list of supported aggregate functions |
| `on_update` | `bool` | Whether update callbacks are supported |