Add pixel statistics

This commit is contained in:
2025-02-12 20:21:33 +01:00
parent 7f9fca22a9
commit 4255c8cb87
12 changed files with 1127 additions and 58 deletions
+10 -1
View File
@@ -11,6 +11,7 @@ import { debounce } from "lodash";
import { TC_LOGO } from "../../constants";
import styled from "styled-components";
import { MuralEx } from "../../interfaces";
import { PieCharts } from "./pixelCharts";
const Img = styled.img`
width: 25px;
@@ -35,6 +36,7 @@ interface Props {
interface State extends StoreSettings{
selected?: MuralEx;
overlays: number[];
showCharts: boolean;
phantomOverlay: number;
overlayModify?: OverlayReturn;
}
@@ -46,6 +48,7 @@ export class Main extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showCharts: false,
overlays: [],
phantomOverlay: -1,
opacity: 50,
@@ -106,8 +109,14 @@ export class Main extends React.Component<Props, State> {
render() {
return <>
<MovableWindow title={this.title()} storage={this.props.storage} storageKey="main" >
{this.state.showCharts ?
<MovableWindow title={"Pixel chart"} storage={this.props.storage} storageKey="charts">
<PieCharts palette={this.props.palette} store={this.props.store}/>
</MovableWindow>
: null}
<MovableWindow title={this.title()} storage={this.props.storage} storageKey="main">
<Menu
showChart={() => this.setState({showCharts: !this.state.showCharts})}
onOpacityChange={this.opacityChange}
cords={this.props.cords}
store={this.props.store}
+23 -5
View File
@@ -1,4 +1,4 @@
import { faCamera, faCaretDown, faCaretUp, faUpload } from "@fortawesome/free-solid-svg-icons";
import { faCamera, faCaretDown, faCaretUp, faPieChart, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import styled from "styled-components";
@@ -76,6 +76,7 @@ interface Props {
storage: Storage;
opacity: number;
collapsed: boolean;
showChart: () => void;
onOpacityChange: (n: number) => void;
onCollapsedChanged: (b: boolean) => void;
}
@@ -130,13 +131,30 @@ export class Menu extends React.Component<Props, State> {
this.setState({takingCanvasShot: false});
};
render() {
return <Container>
return<Container>
<Flex>
<Btn onClick={this.screenshot} disabled={this.state.takingCanvasShot || !this.state.canvasShotAvailable} title="Screenshot"><FontAwesomeIcon icon={ faCamera } /></Btn>
<Btn onClick={this.import} title="Import"><FontAwesomeIcon icon={ faUpload } /></Btn>
<Btn
onClick={this.screenshot}
disabled={this.state.takingCanvasShot || !this.state.canvasShotAvailable}
title="Screenshot">
<FontAwesomeIcon icon={ faCamera } />
</Btn>
<Btn
onClick={this.import}
title="Import">
<FontAwesomeIcon icon={ faUpload } />
</Btn>
<Btn
onClick={() => this.props.showChart()}
title="Pixels graph">
<FontAwesomeIcon icon={ faPieChart } />
</Btn>
<InputRange type="range" min={0} max={100} value={this.props.opacity} onInput={this.inputElement} />
<PercentageDiv>{this.props.opacity}%</PercentageDiv>
<Btn onClick={() => this.props.onCollapsedChanged(!this.props.collapsed)}><FontAwesomeIcon icon={ this.props.collapsed ? faCaretDown : faCaretUp } /></Btn>
<Btn
onClick={() => this.props.onCollapsedChanged(!this.props.collapsed)}>
<FontAwesomeIcon icon={ this.props.collapsed ? faCaretDown : faCaretUp } />
</Btn>
</Flex>
{this.props.collapsed ? null : <MuralList store={this.props.store} palette={this.props.palette} cords={this.props.cords}/> }
</Container>;
+133
View File
@@ -0,0 +1,133 @@
import React from "react";
import { Palette } from "../../lib/palette";
import { Store, StoreEvents } from "../../lib/store";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import styled from "styled-components";
import { formatNumber } from "../../lib/utils";
const ColorBlock = styled.div`
width: 20px;
height: 20px;
margin-right: 5px;
border: 1px solid black;
border-radius: 12px;
`;
const ColorLine = styled.div`
padding: 4px;
display: flex;
flex-direction: row;
font-size: 15px;
`;
const FlexList = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
`;
interface PixelData {
color: string;
value: number;
}
interface State {
total: number;
groupData?: PixelData[];
}
interface Props {
store: Store;
palette: Palette;
}
export class PieCharts extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
total: 0,
};
}
componentDidMount () {
this.refresh();
this.props.store.on(StoreEvents.PixelLog, this.refresh);
}
componentWillUnmount() {
this.props.store.off(StoreEvents.PixelLog, this.refresh);
}
private refresh = async () => {
const colors: string[] = [];
const groupData: PixelData[] = [];
let total = 0;
for (let i = 0; i < this.props.palette.hex.length; i++) {
const hex = this.props.palette.hex[i];
colors.push(hex);
const logs = await this.props.store.fetchPixelsLogByColor(i);
if (logs.length) {
total += logs.length;
groupData.push({
color: hex,
value: logs.length
});
}
}
groupData.sort((a,b) => a.value > b.value ? -1 : 1);
this.setState(({
groupData,
total
}));
};
render(): React.ReactNode
{
if (!this.state.groupData ) {
return <div>Loading</div>;
}
if (!this.state.groupData.length) {
return <div>No data! Please a pixel to see pixel chart</div>;
}
const style:React.CSSProperties = {
width: Math.min(250, window.innerWidth - 20),
height: Math.min(250, window.innerHeight - 20)
};
return <div>
<h5>Total pixels: {formatNumber(this.state.total)}</h5>
<div style={style}>
<ResponsiveContainer width="100%" height="100%">
<PieChart width={400} height={400}>
<Pie
data={this.state.groupData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{this.state.groupData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<div>
<FlexList style={{ width: Math.min(250, window.innerWidth - 20)}}>
{this.state.groupData.map((e, i) => {
return <ColorLine key={i}>
<ColorBlock style={{
backgroundColor: e.color
}}></ColorBlock>{formatNumber(e.value)}</ColorLine>;
})}
</FlexList>
</div>
</div>;
}
}
+7 -10
View File
@@ -4,6 +4,7 @@ import styled from "styled-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSquare } from "@fortawesome/free-solid-svg-icons";
import { formatNumber } from "../../lib/utils";
import { StoreEvents } from "../../lib/store";
const CountBox = styled.div<{width: number}>`
position: fixed;
@@ -48,20 +49,16 @@ export class PixelCount extends React.Component<Props, State> {
};
}
componentDidMount() {
this.props.pixelCount.count.then(countMaybe => {
if (typeof countMaybe === "number") {
this.updateCount(countMaybe);
}
});
this.props.pixelCount.on(this.updateCount);
window.addEventListener("resize", this.onResize);
this.updateCount();
this.props.pixelCount.store.on(StoreEvents.PixelLog, this.updateCount);
window.addEventListener("resize", this.onResize);
}
componentWillUnmount() {
this.props.pixelCount.on(this.updateCount);
this.props.pixelCount.store.off(StoreEvents.PixelLog, this.updateCount);
window.removeEventListener("resize", this.onResize);
}
updateCount = (count: number) => {
this.setState({count});
updateCount = async () => {
this.setState({count: await this.props.pixelCount.getCount()});
};
onResize = () => {
this.setState({ width: window.innerWidth });
+1 -1
View File
@@ -98,7 +98,7 @@ export async function importArtWorks(
const pixels = await imageToMural(img, palette);
const mural = await new Promise<Mural>((resolve, reject)=> {
store.setOverlayModify({
pixels,
mural,
muralObj: {
name: img.alt,
x: Math.floor(cords.ux - (img.width / 2)),
+2 -2
View File
@@ -14,12 +14,12 @@ async function main() {
const palette = new Palette();
while(!palette.init()) { await waitForDraw();}
const storage = new Storage(ENVIRONMENT === "browser-extension");
const pixelPlaced = PixelPlaced.create(storage);
const store = new Store(storage, palette);
await store.load();
const pixelPlaced = PixelPlaced.create(store);
const coordinates = new Coordinates();
await coordinates.init();
createUI(store,storage, coordinates, palette, pixelPlaced);
console.log(
+102 -11
View File
@@ -1,14 +1,105 @@
// Creating artificial API between two worlds
const log = console.log;
console.log = (...args: any) => {
const firstArg = args[0];
if (firstArg && typeof firstArg === "object" && "pixelsPlaced" in firstArg) {
const pixelsPlaced = new CustomEvent("__pixelsPlaced", {
detail: firstArg.pixelsPlaced,
});
document.dispatchEvent(pixelsPlaced);
}
log(...args);
};
export type InterceptBefore = (requestInfo: string, init?: RequestInit) => Promise<void> | void;
export type InterceptAfter = (requestInfo: string, response: Response, init?: RequestInit) => Promise<void> | void;
export class Interceptor {
private static fetch = window.fetch;
private static overwritten = false;
private static before = new Map<string, InterceptBefore[]>();
private static after = new Map<string, InterceptAfter[]>();
private constructor() {}
private static overrideFetch() {
if (this.overwritten) {
return;
}
this.overwritten = true;
(window as any).fetch = async (...args: any) => {
const url = ((args[0] as RequestInfo | URL) || "").toString();
const init = args[1];
const befores = [...(this.before.get(url) || []), ...(this.before.get("*") || [])];
if (befores) {
for (const before of befores) {
await before(url, init);
}
}
const response = (this.fetch as any)(...args);
const afters = [...(this.after.get(url) || []), ...(this.after.get("*") || [])];
if (afters) {
const result = await response;
for (const after of afters) {
try {
await after(url, result, init);
} catch (_) {}
}
return response;
} else {
return response;
}
};
}
static onAfter(url: string, cb: InterceptAfter) {
Interceptor.overrideFetch();
const arr = Interceptor.after.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.after.set(url, arr);
}
static offAfter(url: string, cb: InterceptAfter) {
const arr = Interceptor.after.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.after.set(url, arr);
} else {
Interceptor.after.delete(url);
}
}
static onBefore(url: string, cb: InterceptBefore) {
Interceptor.overrideFetch();
const arr = Interceptor.before.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.before.set(url, arr);
}
static offBefore(url: string, cb: InterceptBefore) {
const arr = Interceptor.before.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.before.set(url, arr);
} else {
Interceptor.before.delete(url);
}
}
}
Interceptor.onAfter("/api/pixel", async (_, response, init) => {
try {
if (response.status === 200 && init && "body" in init) {
const body = JSON.parse(init.body as any);
if ("x" in body && "y" in body && "color" in body) {
const pixelsPlaced = new CustomEvent("__pixelsPlaced", {
detail: { color: body.color, x: body.x, y: body.y },
});
document.dispatchEvent(pixelsPlaced);
}
}
} catch (error) {
console.error(error);
}
});
+86
View File
@@ -0,0 +1,86 @@
export type InterceptBefore = (requestInfo: string, init?: RequestInit) => Promise<void> | void;
export type InterceptAfter = (requestInfo: string, response: Response, init?: RequestInit) => Promise<void> | void;
export class Interceptor {
private static fetch = window.fetch;
private static overwritten = false;
private static before = new Map<string, InterceptBefore[]>();
private static after = new Map<string, InterceptAfter[]>();
private constructor() {}
private static overrideFetch() {
if (this.overwritten) {
return;
}
this.overwritten = true;
(window as any).fetch = async (...args: any) => {
const url = ((args[0] as RequestInfo | URL) || "").toString();
const init = args[1];
const befores = [...(this.before.get(url) || []), ...(this.before.get("*") || [])];
if (befores) {
for (const before of befores) {
await before(url, init);
}
}
const response = (this.fetch as any)(...args);
const afters = [...(this.after.get(url) || []), ...(this.after.get("*") || [])];
if (afters) {
const result = await response;
for (const after of afters) {
try {
await after(url, result, init);
} catch (_) {}
}
return response;
} else {
return response;
}
};
}
static onAfter(url: string, cb: InterceptAfter) {
Interceptor.overrideFetch();
const arr = Interceptor.after.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.after.set(url, arr);
}
static offAfter(url: string, cb: InterceptAfter) {
const arr = Interceptor.after.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.after.set(url, arr);
} else {
Interceptor.after.delete(url);
}
}
static onBefore(url: string, cb: InterceptBefore) {
Interceptor.overrideFetch();
const arr = Interceptor.before.get(url) || [];
if (!arr.includes(cb)) {
arr.push(cb);
}
Interceptor.before.set(url, arr);
}
static offBefore(url: string, cb: InterceptBefore) {
const arr = Interceptor.before.get(url) || [];
const index = arr.indexOf(cb);
if (index !== -1) {
arr.splice(index, 1);
}
if (arr.length) {
Interceptor.before.set(url, arr);
} else {
Interceptor.before.delete(url);
}
}
}
+23 -20
View File
@@ -1,13 +1,16 @@
import { INJECT_SCRIPT_PIXEL_OBSERVER } from "../constants";
import { BasicEventEmitter } from "./eventEmitter";
import { Storage } from "./storage";
import { Store } from "./store";
interface Pixel {
x: number;
y: number;
color: number;
}
export class PixelPlaced {
private emitter = new BasicEventEmitter();
export class PixelPlaced {
private static instance: PixelPlaced;
private readonly storageKey = "__pixelsPlaced";
private constructor(private storage: Storage) {
private constructor(public readonly store: Store) {
// The website is logging pixels as object onto pixelsPlaced onto console with .log.
// With extension we need to cross 2 worlds. Adding artificial api to document
@@ -26,28 +29,28 @@ export class PixelPlaced {
}
document.body.appendChild(api);
(document as any).addEventListener("__pixelsPlaced", (event: CustomEvent<number>) => {
if (typeof event.detail === "number") {
this.storage.setItem(this.storageKey, event.detail);
this.emitter.emit(0, event.detail);
(document as any)
.addEventListener("__pixelsPlaced", (event: CustomEvent<Pixel>) => {
const body = event.detail;
console.log(body);
if (typeof body === "object" && "x" in body && "y" in body && "color" in body
&& typeof body.color === "number" && typeof body.x === "number"
&& typeof body.y === "number"
) {
const data = event.detail;
this.store.addPixelLog(data.x, data.y, data.color);
}
});
}
static create(storage: Storage) {
static create(store: Store) {
if (!this.instance) {
this.instance = new PixelPlaced(storage);
this.instance = new PixelPlaced(store);
}
return this.instance;
}
on(fn: (count: number) => void) {
this.emitter.on(0, fn);
}
off(fn: (count: number) => void) {
this.emitter.off(0, fn);
}
get count() {
return this.storage.getItem(this.storageKey);
getCount() {
return this.store.getPixelCount();
}
}
+133 -4
View File
@@ -16,6 +16,7 @@ export enum StoreEvents {
MuralSelect,
MuralOverlay,
MuralPhantomOverlay,
PixelLog,
Any,
}
@@ -25,23 +26,75 @@ export interface OverlayReturn {
cb: (name: string, x: number, y: number, confirm?: boolean) => void;
}
export interface PixelLog {
timestamp: string;
x: number;
y: number;
color: number;
}
export interface PixelLogEx extends PixelLog {
id: number;
}
export class Store implements LoadUnload {
private readonly STORAGE_KEY_MURAL = "_murals";
private readonly STORAGE_KEY_SELECTED = "_mural";
private readonly STORAGE_KEY_LOG_STORE = "records";
private readonly STORAGE_KEY_PIXEL_LOG = "_pixel-log";
private _murals: MuralEx[] = [];
private _selected?: MuralEx;
private emitter = new BasicEventEmitter();
private _overlayIndices: number[] = [];
private _phantomOverlay = -1;
private _overlayModify?: OverlayReturn;
private dbLog?: IDBDatabase;
private db = localforage.createInstance({
name: "pixel-canvas-overlay"
});
});
constructor(private storage: Storage, private palette: Palette) {}
async load() {
console.log("loaded");
globalThis.store = this;
const pixelCanvasLog = "pixel-canvas-log";
const logDb = indexedDB.open(pixelCanvasLog, 1);
logDb.addEventListener("upgradeneeded", event => {
if (event) {
const db = (event?.target as any).result;
const store = db
.createObjectStore(this.STORAGE_KEY_LOG_STORE,
{ keyPath: "id", autoIncrement: true }
);
store.createIndex("by_timestamp", "timestamp");
store.createIndex("by_color", "color");
}
});
this.dbLog = await new Promise<IDBDatabase | undefined>((resolve, reject) => {
logDb.addEventListener("success", event => {
const db = (event.target as any)?.result as IDBDatabase;
if (db) {
resolve(db);
} else {
reject(new Error("IndexDB did not return db object"));
}
}, { once: true });
logDb.addEventListener("error", event => {
if (confirm(
"Unable to load data. Would you like to continue? Some feature will not work correctly"
)) {
indexedDB.deleteDatabase(pixelCanvasLog);
resolve(undefined);
} else {
reject((event.target as IDBOpenDBRequest).error);
}
}, { once: true });
});
this._murals = [];
const rawMurals = await this.db.getItem<Uint8Array[]>(this.STORAGE_KEY_MURAL) || [];
@@ -77,6 +130,83 @@ export class Store implements LoadUnload {
}
}
}
addPixelLog(x: number, y: number, color: number) {
const timestamp = new Date().toISOString();
const data: PixelLog = {
timestamp,
color,
x,
y
};
return new Promise<IDBValidKey | null>((resolve, reject) => {
if (!this.dbLog) {
resolve(null);
return;
}
const tx = this.dbLog.transaction(this.STORAGE_KEY_LOG_STORE, "readwrite");
const store = tx.objectStore(this.STORAGE_KEY_LOG_STORE);
const request = store.add(data);
request.addEventListener("success", () => {
resolve(request.result);
this.emit(StoreEvents.PixelLog);
}, { once: true });
request.addEventListener("error", () => {
reject(request.error);
}, { once: true });
});
}
async fetchPixelsLogByColor(color: number) {
if (!this.dbLog) return Promise.resolve([]);
const tx = this.dbLog.transaction("records", "readonly");
const store = tx.objectStore("records");
const index = store.index("by_color");
const range = IDBKeyRange.only(color);
const request = index.openCursor(range);
return this.cursorIterator(request);
}
async fetchPixelsLogByTime(startDate: Date, endDate: Date) {
if (!this.dbLog) return Promise.resolve([]);
const tx = this.dbLog.transaction(this.STORAGE_KEY_LOG_STORE, "readonly");
const store = tx.objectStore(this.STORAGE_KEY_LOG_STORE);
const index = store.index("by_timestamp");
const range = IDBKeyRange.bound(startDate.toISOString(), endDate.toISOString());
const request = index.openCursor(range);
return this.cursorIterator(request);
}
async getPixelCount() {
if (!this.dbLog) return Promise.resolve(0);
const tx = this.dbLog.transaction(this.STORAGE_KEY_LOG_STORE, "readonly");
const store = tx.objectStore(this.STORAGE_KEY_LOG_STORE);
return new Promise<number>((resolve, reject) => {
const request = store.count();
request.addEventListener("success", () => {
resolve(request.result);
}, { once: true });
request.addEventListener("error", () => {
reject(request.error);
}, { once: true });
});
}
private cursorIterator(request: IDBRequest<IDBCursorWithValue | null>) {
const results: PixelLogEx[] = [];
return new Promise<PixelLogEx[]>((resolve) => {
request.addEventListener("success", event => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
});
});
}
updateMural(_mural: Mural) {
this.save();
this.emit(StoreEvents.MuralUpdated);
@@ -90,7 +220,6 @@ export class Store implements LoadUnload {
this.emit(StoreEvents.Any);
this.save();
}
remove(mural: MuralEx) {
this._overlayIndices = [];
this._overlayModify = undefined;