├── .gitignore ├── tsconfig.json ├── package.json ├── src ├── custom_heatmap.ts ├── index.html ├── index.tsx ├── index.css ├── config.json └── custom_datagrid.ts ├── webpack.config.js ├── README.md └── scripts ├── image_fetcher.py └── data_fetcher.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | static 3 | node_modules 4 | __pycache__ 5 | .DS_Store 6 | *.arrow 7 | yarn-error.log 8 | dist 9 | images -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es6", 5 | "lib": ["es6", "dom"], 6 | "module": "esnext", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "alwaysStrict": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noImplicitUseStrict": false, 14 | "strictNullChecks": true, 15 | "skipLibCheck": true, 16 | "removeComments": false, 17 | "jsx": "react", 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "importHelpers": true, 22 | "noEmitHelpers": true, 23 | "inlineSourceMap": false, 24 | "sourceMap": true, 25 | "emitDecoratorMetadata": false, 26 | "experimentalDecorators": true, 27 | "downlevelIteration": true, 28 | "pretty": true 29 | }, 30 | "exclude": [ 31 | "node_modules", 32 | "*.arrow" 33 | ] 34 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pudgy-penguins-perspective", 3 | "private": true, 4 | "version": "0.0.1", 5 | "description": "A perspective-workspace to visualize Pudgy Penguin transactions and price movements.", 6 | "scripts": { 7 | "start": "webpack serve --open", 8 | "build": "webpack --color", 9 | "clean": "rimraf dist", 10 | "fetch:data": "python3 scripts/data_fetcher.py", 11 | "fetch:images": "python3 scripts/image_fetcher.py" 12 | }, 13 | "author": "tanjunyuan@hotmail.co.uk", 14 | "license": "Apache-2.0", 15 | "dependencies": { 16 | "@finos/perspective": "^1.0.0", 17 | "@finos/perspective-viewer": "^1.0.0", 18 | "@finos/perspective-viewer-d3fc": "^1.0.0", 19 | "@finos/perspective-viewer-datagrid": "^1.0.0", 20 | "@finos/perspective-workspace": "^1.0.0", 21 | "react": "16.8.6", 22 | "react-dom": "16.8.6", 23 | "style-loader": "^3.3.0" 24 | }, 25 | "devDependencies": { 26 | "@finos/perspective-webpack-plugin": "^1.0.0", 27 | "@types/chroma-js": "^2.1.3", 28 | "@types/react": "^16.8.6", 29 | "@types/react-dom": "^16.9.4", 30 | "chroma": "^0.0.1", 31 | "html-webpack-plugin": "^5.3.2", 32 | "source-map-loader": "^3.0.0", 33 | "ts-loader": "^6.2.1", 34 | "typescript": "^4.4.3", 35 | "webpack": "^5.58.0", 36 | "webpack-cli": "^4.9.0", 37 | "webpack-dev-server": "^4.3.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/custom_heatmap.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | 3 | import type { View } from "@finos/perspective"; 4 | 5 | import type { PerspectiveViewerPluginElement } from "@finos/perspective-viewer"; 6 | 7 | 8 | function create_custom(name: string) { 9 | const D3fcHeatmap = customElements.get(`perspective-viewer-d3fc-${name}`) as typeof PerspectiveViewerPluginElement; 10 | class CustomHeatmap extends D3fcHeatmap { 11 | _style: HTMLStyleElement; 12 | 13 | constructor() { 14 | super(); 15 | this._style = document.createElement("style"); 16 | this._style.innerHTML = ` 17 | line { 18 | stroke: rgba(9, 33, 50) !important; 19 | stroke-dasharray: 4,4 !important; 20 | } 21 | `; 22 | } 23 | 24 | async draw(view: View) { 25 | await super.draw(view); 26 | if (!this._style.isConnected) { 27 | this.shadowRoot?.appendChild(this._style); 28 | } 29 | } 30 | 31 | get name() { 32 | return `Custom ${name.slice(0, 1).toUpperCase() + name.slice(1)}`; 33 | } 34 | } 35 | 36 | customElements.define(`custom-${name}`, CustomHeatmap); 37 | customElements.get("perspective-viewer").registerPlugin(`custom-${name}`); 38 | } 39 | 40 | create_custom("heatmap"); 41 | create_custom("yline"); 42 | create_custom("ybar"); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /****************************************************************************** 2 | * 3 | * Copyright (c) 2017, the Perspective Authors. 4 | * 5 | * This file is part of the Perspective library, distributed under the terms of 6 | * the Apache License 2.0. The full license can be found in the LICENSE file. 7 | * 8 | */ 9 | 10 | const PerspectivePlugin = require("@finos/perspective-webpack-plugin"); 11 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 12 | const path = require("path"); 13 | 14 | module.exports = { 15 | mode: "development", 16 | devtool: "source-map", 17 | resolve: { 18 | extensions: [".ts", ".tsx", ".js"], 19 | }, 20 | 21 | plugins: [ 22 | new HtmlWebPackPlugin({ 23 | title: "Perspective React Example", 24 | template: "./src/index.html", 25 | }), 26 | new PerspectivePlugin(), 27 | ], 28 | 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.js$/, 33 | enforce: "pre", 34 | use: ["source-map-loader"], 35 | }, 36 | { 37 | test: /\.ts(x?)$/, 38 | exclude: /node_modules/, 39 | loader: "ts-loader", 40 | }, 41 | { 42 | test: /\.css$/, 43 | exclude: /node_modules\/monaco-editor/, 44 | use: [{loader: "style-loader"}, {loader: "css-loader"}], 45 | }, 46 | ], 47 | }, 48 | devServer: { 49 | static: [ 50 | {directory: path.join(__dirname, "dist")}, 51 | {directory: path.join(__dirname, "./static")}, 52 | ], 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Perspective Pudgy Penguins NFT Dashboard 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Perspective Pudgy Penguins Sales Dashboard 2 | 3 | Using [Perspective](https://github.com/finos/perspective), this dashboard interactively visualizes and analyzes [Pudgy Penguins](https://pudgypenguins.io/) NFT sales data using the [OpenSea API](https://docs.opensea.io/reference/api-overview). 4 | 5 | ### Explore the dashboard [here](https://sc1f.github.io/pudgy-penguin-perspective/). 6 | 7 | ![Screenshot of dashboard](https://i.imgur.com/fyoJgxQ.png) 8 | 9 | ### What is Perspective? 10 | 11 | [Perspective](https://perspective.finos.org) is an interactive visualization component for large, real-time datasets using a high-performance WebAssembly data engine and visualization layer. Running entirely in the browser, Perspective enables technical and non-technical users to quickly transform, dissect, and visualize their dataset without having to configure a data server or manually construct charts. 12 | 13 | This dashboard also demonstrates some of the innovative and easy ways that Perspective's visualization plugins and appearance can be customized by end-users: 14 | 15 | - [custom_datagrid.ts](https://github.com/sc1f/pudgy-penguin-perspective/blob/master/src/custom_datagrid.ts) shows how Perspective's datagrid can be quickly customized to restyle content and insert new elements using [regular-table](https://github.com/jpmorganchase/regular-table)'s `addStyleListener` API. 16 | * Images for each of the 8,888 penguins are not loaded individually into the grid. Instead, they are pre-encoded into an image that contains each penguin in order. Each image in the datagrid is actually a `` element that renders the correct offset into the parent image. This allows for the browser to efficiently cache the large asset, and minimizes on network calls during scrolling/rendering. 17 | * Where applicable, URLs are rendered for users to click away and explore further details on OpenSea. For example, buyer/seller usernames and wallet addresses are displayed as URLs. 18 | * The custom datagrid does not overwrite the logic of the datagrid bundled with Perspective. Instead, it is implemented as an additional plugin that implements Perspective's plugin API, and is automatically bundled into each `` element on the page. 19 | - [custom_heatmap.ts](https://github.com/sc1f/pudgy-penguin-perspective/blob/master/src/custom_heatmap.ts) shows how the charting plugins (which utilize [d3fc](https://d3fc.io/)) can be quickly customized to display custom colors and other user-defined configuration options. 20 | 21 | Finally, the interaction with the OpenSea API uses `perspective-python` to store and transform the data before exporting the dataset to an Apache Arrow binary stored on disk. 22 | ### Running the dashboard locally 23 | 24 | 1. `git clone` the repository 25 | 2. Install JS and Python dependencies: 26 | 27 | ```bash 28 | $ yarn 29 | $ pip install perspective-python requests pillow 30 | ``` 31 | 3. Run `yarn fetch:images` to download the penguin images and encode them into a single file for the dashboard. 32 | 4. Run `yarn fetch:data` to download the transaction data from the OpenSea API and encode them into an Apache Arrow. 33 | 5. Run `yarn start` to start the Webpack dev server - the dashboard should start running! 34 | ### Dataset 35 | 36 | Using OpenSea's [Events](https://docs.opensea.io/reference/retrieving-asset-events) API, the data fetcher scripts pull all _Sales_ events for Pudgy Penguins. Because bids can be placed arbitarily by any user and don't have to be accepted by the owner, actual sales and transfers are more representative for data analysis and visualization. 37 | 38 | While Perspective is designed for both _static_ and _streaming_ datasets, NFT sales do not "stream" like a traditional order book/liquid market would, so this dashboard only displays static data from 08/12/2021 to 10/11/2021, which is still an excellent corpus for analysis and visualization. -------------------------------------------------------------------------------- /scripts/image_fetcher.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import shutil 3 | import os.path 4 | import os 5 | from PIL import Image 6 | 7 | from datetime import datetime 8 | from math import floor 9 | 10 | PENGUINS_CONTRACT = "0xbd3531da5cf5857e7cfaa92426877b022e612cf8" 11 | ROOT_URL = "https://api.opensea.io/api/v1" 12 | HERE = os.path.dirname(os.path.realpath(__file__)) 13 | 14 | def process_image(json, out): 15 | out[int(json["token_id"])] = json["image_url"] 16 | 17 | def fetch_images(): 18 | url = ROOT_URL + "/assets" 19 | params = { 20 | "asset_contract_address": PENGUINS_CONTRACT, 21 | "limit": 50, 22 | "order_direction": "asc", 23 | "offset": 0 24 | } 25 | TOTAL = 8888 26 | offset = 50 27 | req = requests.get(url, params=params) 28 | json = req.json()["assets"] 29 | 30 | urls = {} 31 | 32 | for asset in json: 33 | process_image(asset, urls) 34 | 35 | while offset < TOTAL or len(urls) < TOTAL: 36 | print("grabbing", offset, offset + 50) 37 | params["offset"] = offset 38 | req = requests.get(url, params=params) 39 | json = req.json()["assets"] 40 | 41 | for asset in json: 42 | process_image(asset, urls) 43 | 44 | print(len(urls)) 45 | offset += 50 46 | 47 | return urls 48 | 49 | def fetch_accounts(): 50 | url = ROOT_URL + "/assets" 51 | params = { 52 | "asset_contract_address": PENGUINS_CONTRACT, 53 | "limit": 50, 54 | "order_direction": "asc", 55 | "offset": 0 56 | } 57 | TOTAL = 8888 58 | offset = 50 59 | req = requests.get(url, params=params) 60 | urls = {} 61 | 62 | for side in ["seller_address", "buyer_address"]: 63 | json = req.json()["assets"] 64 | 65 | for asset in json: 66 | process_image(asset, urls) 67 | 68 | while offset < TOTAL or len(urls) < TOTAL: 69 | print("grabbing", offset, offset + 50) 70 | params["offset"] = offset 71 | req = requests.get(url, params=params) 72 | json = req.json()["assets"] 73 | for row in json: 74 | urls[int(row["token_id"])] = row["image_url"] 75 | 76 | print(len(urls)) 77 | offset += 50 78 | 79 | return urls 80 | 81 | def download_images(images): 82 | SAVE_PATH = os.path.join(HERE, "..", "images") 83 | 84 | for asset_id, image_url in images.items(): 85 | url = image_url + "=s200" 86 | resp = requests.get(url, stream=True) 87 | resp.raw.decode_content = True 88 | name = "{}.png".format(asset_id) 89 | with open(os.path.join(SAVE_PATH, name), "wb") as png: 90 | shutil.copyfileobj(resp.raw, png) 91 | print("saved", name) 92 | 93 | def process_images(): 94 | PATH = os.path.join(HERE, "..", "images") 95 | WIDTH = int(18800 / 4) 96 | HEIGHT = int(19000 / 4) 97 | output_image = Image.new("RGB", (WIDTH, HEIGHT), "white") 98 | x, y = 0, 0 99 | 100 | inputs = os.listdir(PATH) 101 | inputs.sort(key = lambda filename: int(filename.split(".png")[0]) if ".png" in filename else -1) 102 | 103 | lookup = {} 104 | 105 | for input_img in inputs: 106 | if ".png" not in input_img: 107 | continue 108 | try: 109 | with Image.open(os.path.join(PATH, input_img)) as img: 110 | resized = img.resize((50, 50)) 111 | x_remaining = WIDTH - x 112 | y_remaining = HEIGHT - y 113 | 114 | if y_remaining < 0: 115 | print("running out of Y space, saving") 116 | break 117 | 118 | if x_remaining == 0: 119 | print("Breaking to next line, x: {}, y: {}, x_remaining: {}, y_remaining: {}".format(x, y, x_remaining, y_remaining)) 120 | y += 50 121 | x = 0 122 | 123 | box = (x, y) 124 | 125 | asset_id = int(input_img.split(".png")[0]) 126 | 127 | if asset_id in lookup: 128 | raise ValueError("Collision at {}".format(resized.filename)) 129 | 130 | # x0, x1, y0, y1 131 | lookup[asset_id] = [x, x + 50, y, y + 50] 132 | 133 | print(img.filename, box) 134 | 135 | output_image.paste(resized, box) 136 | 137 | x += 50 138 | except OSError as err: 139 | print("Failed at image", input_img, err) 140 | continue 141 | 142 | output_image.save(os.path.join(PATH, "full_{}.jpg".format(datetime.now())), quality=100) 143 | 144 | from json import dumps 145 | 146 | with open(os.path.join(PATH, "lookup.json"), "w") as lookup_json: 147 | lookup_json.write(dumps(lookup)) 148 | 149 | print("Saved!") 150 | 151 | 152 | if __name__ == "__main__": 153 | # Download all images first, if we don't have the source 154 | if not os.path.exists(os.path.join(HERE, "..", "images")): 155 | os.path.mkdir(os.path.join(HERE, "..", "images")) 156 | urls = fetch_images() 157 | download_images(urls) 158 | 159 | # Generate the main image 160 | process_images() 161 | 162 | # TODO: fetch account images 163 | # if not os.path.exists(os.path.join(HERE, "..", "accounts")): 164 | # os.path.mkdir(os.path.join(HERE, "..", "accounts")) 165 | # urls = fetch_accounts() 166 | # download_images(urls) 167 | -------------------------------------------------------------------------------- /scripts/data_fetcher.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pandas as pd 3 | import numpy as np 4 | import requests 5 | import os.path 6 | 7 | from datetime import datetime 8 | from perspective import Table 9 | 10 | PENGUINS_CONTRACT = "0xbd3531da5cf5857e7cfaa92426877b022e612cf8" 11 | ROOT_URL = "https://api.opensea.io/api/v1" 12 | HERE = os.path.dirname(os.path.realpath(__file__)) 13 | 14 | STATIC_PATH = os.path.join(HERE, "..", "static") 15 | ARROW_PATH = os.path.join(HERE, "..", "static", "data.arrow") 16 | CLEANED_PATH = os.path.join(HERE, "..", "static", "cleaned.arrow") 17 | 18 | 19 | def parse_event(event): 20 | if not event or event.get("asset") is None: 21 | print("Could not parse event: no asset field") 22 | return None 23 | 24 | parsed = { 25 | "permalink": event["asset"]["permalink"], 26 | "event_datetime": datetime.fromisoformat(event["created_date"]), 27 | "seller_username": None, 28 | "buyer_username": None, 29 | } 30 | 31 | for k in ("id", "token_id", "num_sales", "name", "image_url"): 32 | v = event["asset"].get(k, None) 33 | parsed["asset_{}".format(k)] = int(v) if k == "token_id" else v 34 | 35 | parsed["payment_token_symbol"] = event["payment_token"].get("symbol", None) 36 | parsed["payment_token_eth_price"] = float( 37 | event["payment_token"].get("eth_price", None) 38 | ) 39 | parsed["payment_token_usd_price"] = float( 40 | event["payment_token"].get("usd_price", None) 41 | ) 42 | 43 | parsed["price"] = float(event["total_price"]) / float( 44 | 10 ** event["payment_token"]["decimals"] 45 | ) 46 | 47 | # parsed["seller_address"] = event["seller"]["address"] 48 | # parsed["buyer_address"] = event["winner_account"]["address"] 49 | 50 | 51 | 52 | 53 | parsed["seller_address"] = event["seller"]["address"] 54 | parsed["seller"] = (event["seller"].get("user", None) or dict()).get("username", None) 55 | parsed["seller_img_url"] = event["seller"]["profile_img_url"] 56 | 57 | parsed["buyer_address"] = event["winner_account"]["address"] 58 | parsed["buyer"] = (event["winner_account"].get("user", None) or dict()).get("username", None) 59 | parsed["buyer_img_url"] = event["winner_account"]["profile_img_url"] 60 | 61 | 62 | parsed["transaction_timestamp"] = datetime.fromisoformat( 63 | event["transaction"]["timestamp"] 64 | ) 65 | parsed["transaction_hash"] = event["transaction"]["transaction_hash"] 66 | 67 | return parsed 68 | 69 | 70 | def fetch_events(contract_address): 71 | """Fetches asset sales for the provided contract address.""" 72 | url = "{}/events".format(ROOT_URL) 73 | offset = 0 74 | limit = 200 75 | params = { 76 | "asset_contract_address": contract_address, 77 | "limit": limit, 78 | "event_type": "successful", 79 | "only_opensea": True, 80 | "offset": offset, 81 | } 82 | 83 | res = requests.get(url, params=params) 84 | 85 | if not res.ok: 86 | print("Could not fetch events, reason: {}", res.reason) 87 | return 88 | 89 | events = res.json()["asset_events"] 90 | data = [event for event in (parse_event(ev) for ev in events) if event is not None] 91 | 92 | print("Fetched {} initial records".format(len(data))) 93 | 94 | while len(events) == limit: 95 | offset += limit 96 | print("Fetching records {} to {}".format(offset, offset + limit)) 97 | params["offset"] = offset 98 | res = requests.get(url, params=params) 99 | 100 | if not res.ok: 101 | print("Could not fetch events, returning already fetched events, reason: {}", res.reason) 102 | break 103 | 104 | res_json = res.json() 105 | 106 | if "asset_events" not in res_json: 107 | print("No more events in json: {}".format(res_json)) 108 | break 109 | 110 | events = res_json["asset_events"] 111 | 112 | for ev in events: 113 | try: 114 | parsed = parse_event(ev) 115 | if parsed is not None: 116 | data.append(parsed) 117 | except: 118 | print("Could not parse event: {}".format(ev)) 119 | continue 120 | 121 | 122 | df = pd.DataFrame(data) 123 | return df 124 | 125 | def clean_existing_arrow(): 126 | new_arrow = None 127 | with open(ARROW_PATH, "rb") as arr: 128 | table = Table(arr.read(), index="transaction_hash") 129 | cols = table.columns() 130 | view = table.view(columns=[c for c in cols if c not in ("seller_username", "buyer_username")]) 131 | df = view.to_df() 132 | df["image"] = df["asset_token_id"] 133 | t2 = Table(df, index="transaction_hash") 134 | new_arrow = t2.view(columns=[c for c in t2.columns() if c != "index"]).to_arrow() 135 | 136 | with open(CLEANED_PATH, "wb") as new_arrow_binary: 137 | new_arrow_binary.write(new_arrow) 138 | 139 | print("Saved new cleaned.arrow") 140 | 141 | 142 | if __name__ == "__main__": 143 | if not os.path.exists(ARROW_PATH) or not os.path.exists(CLEANED_PATH): 144 | try: 145 | os.mkdir(STATIC_PATH) 146 | except: 147 | pass 148 | df = fetch_events(PENGUINS_CONTRACT) 149 | table = Table(df, index="transaction_hash") 150 | arrow = table.view().to_arrow() 151 | with open(ARROW_PATH, "wb") as arrow_binary: 152 | arrow_binary.write(arrow) 153 | print("Saved arrow to: {}".format(ARROW_PATH)) 154 | 155 | clean_existing_arrow() 156 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | 3 | import * as React from "react"; 4 | import * as ReactDOM from "react-dom"; 5 | import {useEffect, useRef} from "react"; 6 | import perspective, {Table} from "@finos/perspective"; 7 | import chroma from "chroma-js"; 8 | 9 | import "@finos/perspective-workspace"; 10 | import "@finos/perspective-viewer-datagrid"; 11 | import "@finos/perspective-viewer-d3fc"; 12 | 13 | import "./index.css"; 14 | import "@finos/perspective-workspace/dist/umd/material.dark.css"; 15 | 16 | import default_config from "./config.json"; 17 | 18 | import "./custom_heatmap"; 19 | import "./custom_datagrid"; 20 | 21 | window.chroma = chroma; 22 | 23 | // Required because perspective-workspace doesn't export type declarations 24 | declare global { 25 | namespace JSX { 26 | interface IntrinsicElements { 27 | "perspective-workspace": React.DetailedHTMLProps< 28 | React.HTMLAttributes, 29 | HTMLElement 30 | >; 31 | } 32 | } 33 | } 34 | 35 | const worker = perspective.shared_worker(); 36 | 37 | const getTable = async (): Promise => { 38 | const req = fetch("./cleaned.arrow"); 39 | const resp = await req; 40 | const buffer = await resp.arrayBuffer(); 41 | return await worker.table(buffer as any); 42 | }; 43 | 44 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 45 | const Workspace = (): React.ReactElement => { 46 | const workspace = useRef(null); 47 | 48 | useEffect(() => { 49 | if (workspace.current) { 50 | // Restore a saved config or default 51 | let config = window.localStorage.getItem( 52 | "pudgy_penguins_perspective_workspace_config" 53 | ); 54 | 55 | const layout = config ? JSON.parse(config) : default_config; 56 | 57 | (async function () { 58 | await workspace.current.restore(layout); 59 | await workspace.current.flush(); 60 | })(); 61 | 62 | workspace.current.addTable("asset_events", getTable()); 63 | const progress = document.getElementById("progress"); 64 | progress?.setAttribute("style", "display:none;"); 65 | 66 | workspace.current.addEventListener( 67 | "workspace-layout-update", 68 | async () => { 69 | const config = await workspace.current.save(); 70 | console.debug("Saving to localStorage:", config); 71 | window.localStorage.setItem( 72 | "pudgy_penguins_perspective_workspace_config", 73 | JSON.stringify(config) 74 | ); 75 | } 76 | ); 77 | } 78 | }); 79 | 80 | return ; 81 | }; 82 | 83 | const Footer = (): React.ReactElement => { 84 | const resetLayout = () => { 85 | const workspace: any = document.getElementsByTagName( 86 | "perspective-workspace" 87 | )[0]; 88 | workspace.restore(default_config); 89 | }; 90 | 91 | return ( 92 |
93 |
94 | 99 | 105 | 106 | 107 | 108 |

109 | Built with{" "} 110 | 114 | Perspective 115 | 116 |

117 |

118 | Data from{" "} 119 | 123 | OpenSea 124 | {" "} 125 |

126 |
127 | 130 |
131 | ); 132 | }; 133 | 134 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 135 | const App = (): React.ReactElement => { 136 | return ( 137 |
138 | 139 |
140 |
141 | ); 142 | }; 143 | 144 | window.addEventListener("load", () => { 145 | ReactDOM.render(, document.getElementById("root")); 146 | }); 147 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lato&display=swap'); 2 | 3 | #main, .modal-background { 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | } 10 | 11 | .container { 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .footer { 17 | background: rgb(7, 8, 29) !important; 18 | color: white; 19 | border-top: 1px solid rgb(9, 33, 50); 20 | display: flex; 21 | flex-direction: row; 22 | align-content: space-between; 23 | align-items: center; 24 | justify-content: space-between; 25 | height: 30px; 26 | font-family: "Lato"; 27 | padding: 10px; 28 | } 29 | 30 | .footer path { 31 | stroke: rgb(66, 182, 230); 32 | fill: rgb(66, 182, 230); 33 | transition: stroke 0.5s, fill 0.5s; 34 | } 35 | 36 | .footer-meta { 37 | display: flex; 38 | font-size: 12px; 39 | align-items: center; 40 | } 41 | 42 | .footer-meta a:hover, .footer-meta a:hover:visited { 43 | color: white; 44 | } 45 | 46 | .footer-meta a, .footer-meta a:visited { 47 | color: rgb(66, 182, 230); 48 | transition: color 0.5s; 49 | } 50 | 51 | #github-link { 52 | margin-left: 10px; 53 | margin-right: 10px; 54 | } 55 | 56 | .footer #github-link:hover path { 57 | stroke: white; 58 | fill: white; 59 | } 60 | 61 | .footer-meta p { 62 | color: white; 63 | margin-left: 10px; 64 | margin-right: 10px; 65 | } 66 | 67 | #reset_config { 68 | background: transparent; 69 | border: 1px solid rgb(9, 33, 50); 70 | color: rgb(66, 182, 230); 71 | font-family: 'Lato' !important; 72 | font-size: 11px; 73 | padding: 10px; 74 | cursor: pointer; 75 | } 76 | 77 | #reset_config:hover { 78 | /* background: #eaeaea; */ 79 | border-color: rgb(66, 182, 230); 80 | color: rgb(66, 182, 230); 81 | } 82 | 83 | a.data-permalink { 84 | color: rgb(51, 141, 205); 85 | } 86 | 87 | a.data-permalink:visited { 88 | color: rgb(51, 141, 205); 89 | } 90 | 91 | custom-datagrid regular-table { 92 | font-family: "Lato"; 93 | } 94 | 95 | custom-datagrid regular-table td { 96 | font-size: 16px; 97 | } 98 | 99 | custom-datagrid regular-table tbody tr { 100 | height: 50px !important; 101 | } 102 | 103 | perspective-viewer.workspace-master-widget custom-datagrid regular-table tbody tr:first-of-type { 104 | height: auto !important; 105 | } 106 | 107 | #progress { 108 | background: rgb(7, 8, 29); 109 | position: absolute; 110 | top: 0; 111 | left: 0; 112 | width: 100%; 113 | height: 100%; 114 | z-index: 100000; 115 | } 116 | 117 | .slider { 118 | position: absolute; 119 | width: 250px; 120 | height: 10px; 121 | overflow-x: hidden; 122 | position: absolute; 123 | top: 50%; 124 | left: 50%; 125 | margin-left: -125px; 126 | margin-top: -5px; 127 | z-index: 1000000; 128 | } 129 | 130 | .line { 131 | position: absolute; 132 | opacity: 0.4; 133 | background: rgb(40, 43, 105); 134 | width: 150%; 135 | height: 5px; 136 | } 137 | 138 | .subline { 139 | position: absolute; 140 | background: rgb(60, 63, 122); 141 | height: 5px; 142 | } 143 | 144 | .inc { 145 | animation: increase 2s infinite; 146 | } 147 | 148 | .dec { 149 | animation: decrease 2s 0.5s infinite; 150 | } 151 | 152 | @keyframes increase { 153 | from { 154 | left: -5%; 155 | width: 5%; 156 | } 157 | 158 | to { 159 | left: 130%; 160 | width: 100%; 161 | } 162 | } 163 | 164 | @keyframes decrease { 165 | from { 166 | left: -80%; 167 | width: 80%; 168 | } 169 | 170 | to { 171 | left: 110%; 172 | width: 10%; 173 | } 174 | } 175 | 176 | perspective-viewer.workspace-detail-widget regular-table table tbody tr td, 177 | perspective-viewer.workspace-detail-widget regular-table table tbody tr th { 178 | border-top-color: var(--pp-color-1) !important; 179 | /* border-top-color: rgb(173, 228, 246) !important; */ 180 | } 181 | 182 | perspective-viewer.workspace-detail-widget regular-table table tbody th:empty { 183 | background: linear-gradient(to right, transparent 9px, var(--pp-color-1) 10px, transparent 11px) !important; 184 | background-repeat: no-repeat; 185 | background-position: 0px -10px; 186 | } 187 | 188 | .is-timestamp:not(.psp-is-width-override) { 189 | max-width: 120px !important; 190 | min-width: 120px !important; 191 | width: 120px !important; 192 | } 193 | 194 | td canvas { 195 | margin-bottom: -4px; 196 | } 197 | 198 | td.penguin-canvas { 199 | padding: 0px; 200 | } 201 | 202 | td.is-timestamp:not(.psp-is-width-override) { 203 | white-space: break-spaces; 204 | } 205 | 206 | regular-table { 207 | --pp-color-1: rgb(9, 33, 50); 208 | --pp-color-2: rgb(66, 182, 230); 209 | --rt-pos-cell--color: rgb(66, 182, 230) !important; 210 | } 211 | 212 | regular-table td, th { 213 | --rt-hover--border-color: var(--pp-color-1) !important; 214 | } 215 | 216 | perspective-viewer.workspace-detail-widget { 217 | --inactive--color: rgb(19, 33, 50) !important; 218 | --plugin--border: 1px solid rgb(19, 33, 50) !important; 219 | --plugin--background: rgb(7, 8, 29) !important; 220 | background: rgb(7, 8, 29) !important; 221 | } 222 | 223 | perspective-viewer { 224 | font-family: 'Lato' !important; 225 | --d3fc-positive--gradient: linear-gradient( 226 | #000080, 227 | #7d007e, 228 | #c0006f, 229 | #f10057, 230 | #ff473b, 231 | #ff8a0b, 232 | #ffc600, 233 | #ffff00 234 | ) !important; 235 | 236 | --d3fc-negative--gradient: linear-gradient( 237 | #000080, 238 | #7d007e, 239 | #c0006f, 240 | #f10057, 241 | #ff473b, 242 | #ff8a0b, 243 | #ffc600, 244 | #ffff00 245 | ) !important; 246 | 247 | --d3fc-full--gradient: linear-gradient( 248 | #000080, 249 | #7d007e, 250 | #c0006f, 251 | #f10057, 252 | #ff473b, 253 | #ff8a0b, 254 | #ffc600, 255 | #ffff00 256 | ) !important; 257 | } 258 | 259 | perspective-workspace { 260 | background: rgb(7, 8, 29) !important; 261 | --workspace-tabbar--background-color: rgb(7, 8, 29) !important; 262 | --workspace-tabbar--border-color: rgb(9, 33, 50) !important; 263 | --workspace-tabbar--border: 1px solid rgb(9, 33, 50) !important; 264 | } -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "sizes": [ 3 | 1 4 | ], 5 | "detail": { 6 | "main": { 7 | "type": "split-area", 8 | "orientation": "horizontal", 9 | "children": [ 10 | { 11 | "type": "split-area", 12 | "orientation": "vertical", 13 | "children": [ 14 | { 15 | "type": "tab-area", 16 | "widgets": [ 17 | "PERSPECTIVE_GENERATED_ID_1", 18 | "PERSPECTIVE_GENERATED_ID_2" 19 | ], 20 | "currentIndex": 0 21 | }, 22 | { 23 | "type": "tab-area", 24 | "widgets": [ 25 | "PERSPECTIVE_GENERATED_ID_4" 26 | ], 27 | "currentIndex": 0 28 | } 29 | ], 30 | "sizes": [ 31 | 0.5, 32 | 0.5 33 | ] 34 | }, 35 | { 36 | "type": "split-area", 37 | "orientation": "vertical", 38 | "children": [ 39 | { 40 | "type": "tab-area", 41 | "widgets": [ 42 | "PERSPECTIVE_GENERATED_ID_0" 43 | ], 44 | "currentIndex": 0 45 | }, 46 | { 47 | "type": "tab-area", 48 | "widgets": [ 49 | "PERSPECTIVE_GENERATED_ID_5" 50 | ], 51 | "currentIndex": 0 52 | } 53 | ], 54 | "sizes": [ 55 | 0.5, 56 | 0.5 57 | ] 58 | } 59 | ], 60 | "sizes": [ 61 | 0.5, 62 | 0.5 63 | ] 64 | } 65 | }, 66 | "mode": "globalFilters", 67 | "viewers": { 68 | "PERSPECTIVE_GENERATED_ID_1": { 69 | "plugin": "Custom Heatmap", 70 | "plugin_config": {}, 71 | "settings": false, 72 | "row_pivots": [ 73 | "bucket(\"transaction_timestamp\", 'D')" 74 | ], 75 | "column_pivots": [ 76 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)" 77 | ], 78 | "columns": [ 79 | "\"price\" * \"payment_token_usd_price\"" 80 | ], 81 | "filter": [ 82 | [ 83 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)", 84 | "<", 85 | 35000 86 | ] 87 | ], 88 | "sort": [ 89 | [ 90 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)", 91 | "col desc" 92 | ] 93 | ], 94 | "expressions": [ 95 | "bucket(\"transaction_timestamp\", 'D')", 96 | "hour_of_day(\"transaction_timestamp\")", 97 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)", 98 | "\"price\" * \"payment_token_usd_price\"" 99 | ], 100 | "aggregates": { 101 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)": "dominant" 102 | }, 103 | "master": false, 104 | "name": "Px by price bucket", 105 | "table": "asset_events", 106 | "linked": false 107 | }, 108 | "PERSPECTIVE_GENERATED_ID_2": { 109 | "plugin": "Custom Heatmap", 110 | "plugin_config": { 111 | "zoom": { 112 | "k": 1, 113 | "x": 0, 114 | "y": 0 115 | } 116 | }, 117 | "settings": false, 118 | "row_pivots": [ 119 | "bucket(\"transaction_timestamp\", 'D')" 120 | ], 121 | "column_pivots": [ 122 | "hour_of_day(\"transaction_timestamp\")" 123 | ], 124 | "columns": [ 125 | "buyer_address" 126 | ], 127 | "filter": [], 128 | "sort": [], 129 | "expressions": [ 130 | "bucket(\"transaction_timestamp\", 'D')", 131 | "hour_of_day(\"transaction_timestamp\")", 132 | "bucket(\"price\" * \"payment_token_usd_price\", 1000)", 133 | "\"price\" * \"payment_token_usd_price\"" 134 | ], 135 | "aggregates": {}, 136 | "master": false, 137 | "name": "Px by hour of day", 138 | "table": "asset_events", 139 | "linked": false 140 | }, 141 | "PERSPECTIVE_GENERATED_ID_4": { 142 | "plugin": "Custom Ybar", 143 | "plugin_config": {}, 144 | "settings": true, 145 | "row_pivots": [ 146 | "bucket(\"transaction_timestamp\", 'D')" 147 | ], 148 | "column_pivots": [ 149 | "payment_token_symbol" 150 | ], 151 | "columns": [ 152 | "transaction_hash" 153 | ], 154 | "filter": [ 155 | [ 156 | "payment_token_symbol", 157 | "!=", 158 | "DAI" 159 | ] 160 | ], 161 | "sort": [], 162 | "expressions": [ 163 | "bucket(\"transaction_timestamp\", 'D')", 164 | "\"price\" * \"payment_token_usd_price\"" 165 | ], 166 | "aggregates": { 167 | "transaction_hash": "count" 168 | }, 169 | "master": false, 170 | "name": "# Transactions over time", 171 | "table": "asset_events", 172 | "linked": false 173 | }, 174 | "PERSPECTIVE_GENERATED_ID_0": { 175 | "plugin": "Custom Datagrid", 176 | "plugin_config": { 177 | "buyer": { 178 | "column_size_override": 192.688 179 | }, 180 | "seller": { 181 | "column_size_override": 170.469 182 | }, 183 | "transaction_timestamp": { 184 | "column_size_override": 153 185 | } 186 | }, 187 | "settings": false, 188 | "row_pivots": [], 189 | "column_pivots": [], 190 | "columns": [ 191 | "image", 192 | "transaction_timestamp", 193 | "asset_name", 194 | "Price USD", 195 | "buyer", 196 | "seller", 197 | "permalink" 198 | ], 199 | "filter": [], 200 | "sort": [ 201 | ["transaction_timestamp", "desc"] 202 | ], 203 | "expressions": [ 204 | "// Price USD\n\"price\" * \"payment_token_usd_price\"" 205 | ], 206 | "aggregates": { 207 | "image": "unique" 208 | }, 209 | "master": false, 210 | "name": "Blotter", 211 | "table": "asset_events", 212 | "linked": false 213 | }, 214 | "PERSPECTIVE_GENERATED_ID_5": { 215 | "plugin": "Custom Datagrid", 216 | "plugin_config": {}, 217 | "settings": false, 218 | "row_pivots": [ 219 | "bucket(\"image\", 100)" 220 | ], 221 | "column_pivots": [ 222 | "\"image\" % 100" 223 | ], 224 | "columns": [ 225 | "image" 226 | ], 227 | "filter": [], 228 | "sort": [], 229 | "expressions": [ 230 | "bucket(\"image\", 100)", 231 | "\"image\" % 100" 232 | ], 233 | "aggregates": { 234 | "image": "unique" 235 | }, 236 | "master": false, 237 | "name": "All", 238 | "table": "asset_events", 239 | "linked": false 240 | } 241 | } 242 | } -------------------------------------------------------------------------------- /src/custom_datagrid.ts: -------------------------------------------------------------------------------- 1 | import type {View} from "@finos/perspective"; 2 | import type { 3 | PerspectiveViewerElement, 4 | PerspectiveViewerPluginElement, 5 | } from "@finos/perspective-viewer"; 6 | 7 | function style_listener( 8 | viewer_id: any, 9 | image: any, 10 | table: any, 11 | viewer: any 12 | ): void { 13 | viewer.save().then((config: any) => { 14 | const html_table = table.children[0].children[1]; 15 | const num_rows = html_table.rows.length; 16 | const num_row_pivots = config["row_pivots"]?.length || 0; 17 | const rpidx = config["row_pivots"]?.indexOf("image"); 18 | 19 | for ( 20 | let ridx = 0; 21 | ridx < table.children[0].children[0].rows.length; 22 | ridx++ 23 | ) { 24 | const row = table.children[0].children[0].rows[ridx]; 25 | for (let cidx = 0; cidx < row.cells.length; cidx++) { 26 | const th = row.cells[cidx]; 27 | const meta = table.getMeta(th); 28 | const type = 29 | table.parentElement.model._schema[ 30 | meta.column_header[meta.column_header.length - 1] 31 | ]; 32 | th.classList.toggle( 33 | "is-timestamp", 34 | type === "date" || type === "datetime" 35 | ); 36 | } 37 | } 38 | 39 | for (let ridx = 0; ridx < num_rows; ridx++) { 40 | const row = html_table.rows[ridx]; 41 | for (let cidx = 0; cidx < row.cells.length; cidx++) { 42 | const td = row.cells[cidx]; 43 | const meta = table.getMeta(td); 44 | 45 | if (!meta) continue; 46 | 47 | // Render when pivoted on "image", and don't render 48 | // if the "image" column is also in the view. 49 | if ( 50 | num_row_pivots > 0 && 51 | rpidx !== -1 && 52 | rpidx + 1 === meta.row_header_x && 53 | meta.value !== "" 54 | ) { 55 | const row_path = meta.value.toString().replace(/,/g, ""); 56 | 57 | if (!row_path) { 58 | continue; 59 | } 60 | 61 | const asset_id = row_path; 62 | td.innerHTML = ""; 63 | td.appendChild(makeCanvas(viewer_id, asset_id, image)); 64 | 65 | break; 66 | } 67 | 68 | const type = 69 | table.parentElement.model._schema[ 70 | meta.column_header?.[meta.column_header?.length - 1] 71 | ]; 72 | td.classList.toggle( 73 | "is-timestamp", 74 | type === "date" || type === "datetime" 75 | ); 76 | 77 | // Don't render top row for non pivoted views. 78 | // if (ridx == 0) break; 79 | 80 | const match_image = matchColumn(meta, "image"); 81 | td.classList.toggle("penguin-canvas", match_image); 82 | 83 | // Or when "image" is shown, but not for total rows 84 | if (match_image) { 85 | const asset_id = meta.user; 86 | 87 | if (!asset_id) { 88 | continue; 89 | } 90 | 91 | td.innerHTML = ""; 92 | td.appendChild(makeCanvas(viewer_id, asset_id, image)); 93 | } else if ( 94 | matchColumn(meta, "permalink") || 95 | matchColumn(meta, "asset_image_url") 96 | ) { 97 | // Render HTML links for permalink 98 | const url = td.innerText; 99 | td.innerHTML = ""; 100 | const a = document.createElement("a"); 101 | a.href = url; 102 | a.target = "blank"; 103 | 104 | // remove https:// from display 105 | a.innerText = url.substring(8); 106 | 107 | if (url.includes("googleusercontent")) { 108 | a.innerText = `${url.substring(8, 30)}...`; 109 | } 110 | 111 | a.classList.add("data-permalink"); 112 | td.appendChild(a); 113 | } else if ( 114 | matchColumn(meta, "buyer") || 115 | matchColumn(meta, "seller") || 116 | matchColumn(meta, "buyer_address") || 117 | matchColumn(meta, "seller_address") 118 | ) { 119 | // Render HTML links for OpenSea profile 120 | const content = td.innerText; 121 | 122 | if (content && content !== "-") { 123 | const url = `https://opensea.io/${content}`; 124 | const a = document.createElement("a"); 125 | a.href = url; 126 | a.target = "blank"; 127 | a.innerText = content; 128 | a.classList.add("data-permalink"); 129 | td.innerHTML = ""; 130 | td.appendChild(a); 131 | } 132 | } 133 | } 134 | } 135 | }); 136 | } 137 | 138 | const Datagrid = customElements.get( 139 | "perspective-viewer-datagrid" 140 | ) as typeof PerspectiveViewerPluginElement; 141 | class CustomDatagrid extends Datagrid { 142 | _initialized_datagrid = false; 143 | 144 | // THis hack prevents a bug in workspace due to hard-coded plugin name 145 | // "Datagrid", but causes console errors ... 146 | connectedCallback() { 147 | const viewer = this.parentElement as PerspectiveViewerElement; 148 | viewer.addEventListener("perspective-click", (event) => { 149 | (event as any).detail.config = {}; 150 | }); 151 | } 152 | 153 | async delete() { 154 | console.debug("WIP"); 155 | } 156 | 157 | async draw(view: View) { 158 | await super.draw(view); 159 | if (!this._initialized_datagrid) { 160 | this._initialized_datagrid = true; 161 | const table = this.children[0] as any; 162 | const viewer = this.parentElement as PerspectiveViewerElement; 163 | const image: HTMLImageElement = new Image(); 164 | const task = new Promise((resolve, reject) => { 165 | image.onload = resolve; 166 | image.onerror = reject; 167 | }); 168 | 169 | image.src = "./thumbnails.jpg"; 170 | await task; 171 | 172 | table.addStyleListener(() => { 173 | const viewer_id = viewer.getAttribute("slot") as string; 174 | LAST_CANVAS[viewer_id] = 0; 175 | style_listener(viewer_id, image, table, viewer); 176 | }); 177 | 178 | await super.draw(view); 179 | } 180 | } 181 | 182 | get name() { 183 | return "Custom Datagrid"; 184 | } 185 | } 186 | 187 | customElements.define("custom-datagrid", CustomDatagrid); 188 | customElements.get("perspective-viewer").registerPlugin("custom-datagrid"); 189 | 190 | const VIEWER_CACHE: Record> = {}; 191 | const LAST_CANVAS: Record = {}; 192 | const MAX_CACHE = 800; 193 | 194 | const makeCanvas = ( 195 | viewer_id: string, 196 | asset_id: number, 197 | image: HTMLImageElement 198 | ): HTMLCanvasElement => { 199 | LAST_CANVAS[viewer_id] = LAST_CANVAS[viewer_id] || 0; 200 | VIEWER_CACHE[viewer_id] = VIEWER_CACHE[viewer_id] || []; 201 | 202 | const cache = VIEWER_CACHE[viewer_id]; 203 | 204 | if (LAST_CANVAS[viewer_id] >= MAX_CACHE) { 205 | LAST_CANVAS[viewer_id] = 0; 206 | } 207 | 208 | const canvas = (cache[LAST_CANVAS[viewer_id]] = 209 | cache[LAST_CANVAS[viewer_id]] || 210 | (() => { 211 | const canvas: HTMLCanvasElement = document.createElement("canvas"); 212 | canvas.width = 50; 213 | canvas.height = 50; 214 | return canvas; 215 | })()); 216 | 217 | const ctx = canvas.getContext("2d", {alpha: false}); 218 | const sx = (asset_id % 94) * 50; 219 | const sy = Math.floor(asset_id / 94) * 50; 220 | ctx?.drawImage(image, sx, sy, 50, 50, 0, 0, 50, 50); 221 | 222 | LAST_CANVAS[viewer_id]++; 223 | 224 | return canvas; 225 | }; 226 | 227 | const matchColumn = (meta: any, column_name: string): boolean => { 228 | return ( 229 | !!meta.column_header && 230 | // meta?.column_header?.length === 1 && 231 | meta.column_header[meta?.column_header?.length - 1] === column_name 232 | ); 233 | }; 234 | --------------------------------------------------------------------------------