Skip to content

Commit 1184974

Browse files
committed
Allow export of current eligible charts, fixes #307
1 parent e5e980c commit 1184974

File tree

8 files changed

+190
-84
lines changed

8 files changed

+190
-84
lines changed

docs/images/eligible-charts.png

237 KB
Loading

docs/readme.md

+4
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ Second, it can also be handy to summarize an entire game's song library by using
133133

134134
![Screenshot of eligible charts toggle enabled](images/eligible-charts.png)
135135

136+
### Export Chart Data
137+
138+
While viewing the list of eligible charts, the "Export Chart Data" button (icon only for mobile devices) will save the current list of charts as a CSV file for future reference or data processing.
139+
136140
## Save as Image
137141

138142
Each drawn set has a camera icon in the top right. This saves an image of the current set with a transparent background suitable for use in stream layouts and other such designs. Resize your browser window to adjust the aspect ratio of the generated image.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@types/fuzzy-search": "^2.1.5",
5151
"@types/inquirer": "^9.0.7",
5252
"@types/node": "^20.8.9",
53+
"@types/papaparse": "^5",
5354
"@types/react": "^18.2.33",
5455
"@types/react-dom": "^18.2.19",
5556
"@typescript-eslint/eslint-plugin": "^7.0.2",
@@ -89,6 +90,7 @@
8990
"nanoid": "^5.0.6",
9091
"normalize.css": "^8.0.1",
9192
"p-queue": "^8.0.1",
93+
"papaparse": "^5.4.1",
9294
"peerjs": "^2.0.0-beta.3",
9395
"postcss": "^8.4.35",
9496
"postcss-loader": "^8.1.0",

src/config-persistence.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ConfigState, useConfigState } from "./config-state";
22
import { useDrawState } from "./draw-state";
33
import { toaster } from "./toaster";
4-
import { shareData } from "./utils/share";
4+
import { buildDataUri, dateForFilename, shareData } from "./utils/share";
55

66
interface PersistedConfigV1 {
77
version: 1;
@@ -31,11 +31,14 @@ type Serialized<T extends object> = {
3131

3232
export function saveConfig() {
3333
const persistedObj = buildPersistedConfig();
34-
const dataUri = `data:application/json,${encodeURI(
34+
const dataUri = buildDataUri(
3535
JSON.stringify(persistedObj, undefined, 2),
36-
)}`;
36+
"application/json",
37+
"url",
38+
);
39+
3740
return shareData(dataUri, {
38-
filename: `card-draw-config-${persistedObj.dataSetName}.json`,
41+
filename: `ddr-tools-config-${persistedObj.dataSetName}-${dateForFilename()}.json`,
3942
methods: [
4043
{ type: "nativeShare", allowDesktop: true },
4144
{ type: "download" },

src/eligible-charts/index.tsx

+24-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { useDrawState } from "../draw-state";
44
import { SongCard } from "../song-card";
55
import styles from "../drawing-list.css";
66
import { EligibleChart } from "../models/Drawing";
7-
import { Navbar, NavbarGroup, NavbarDivider, Spinner } from "@blueprintjs/core";
7+
import {
8+
Navbar,
9+
NavbarGroup,
10+
NavbarDivider,
11+
Spinner,
12+
Button,
13+
} from "@blueprintjs/core";
814
import { useIsNarrow } from "../hooks/useMediaQuery";
915
import { useAtom } from "jotai";
10-
import { useDeferredValue, useMemo } from "react";
16+
import { useCallback, useDeferredValue, useMemo } from "react";
1117
import { currentTabAtom, EligibleChartsListFilter } from "./filter";
1218
import { DiffHistogram } from "./histogram";
1319
import { isDegrs, TesterCard } from "../controls/degrs-tester";
20+
import { Export } from "@blueprintjs/icons";
21+
import { shareCharts } from "../utils/share";
1422

1523
function songKeyFromChart(chart: EligibleChart) {
1624
return `${chart.name}:${chart.artist}`;
@@ -39,6 +47,10 @@ export default function EligibleChartsList() {
3947
return [songs, filtered];
4048
}, [charts, isDisplayFiltered, currentTab]);
4149

50+
const exportData = useCallback(async () => {
51+
shareCharts(filteredCharts);
52+
}, [filteredCharts]);
53+
4254
if (!gameData) {
4355
return <Spinner />;
4456
}
@@ -61,6 +73,16 @@ export default function EligibleChartsList() {
6173
<EligibleChartsListFilter />
6274
</NavbarGroup>
6375
)}
76+
<NavbarGroup align="right">
77+
<Button
78+
aria-label={isNarrow ? "Export Chart Data" : undefined}
79+
title="Export current chart data as a CSV file"
80+
icon={<Export />}
81+
onClick={exportData}
82+
>
83+
{isNarrow ? "" : "Export Chart Data"}
84+
</Button>
85+
</NavbarGroup>
6486
</Navbar>
6587
<DiffHistogram charts={filteredCharts} />
6688
<div className={styles.chartList}>

src/tournament-mode/drawing-actions.tsx

+73-75
Original file line numberDiff line numberDiff line change
@@ -85,84 +85,82 @@ export function DrawingActions() {
8585
);
8686

8787
return (
88-
<>
89-
<div className={styles.networkButtons}>
90-
{syncPeer && <Icon icon={<Changes />} intent="success" />}
91-
{isConnected ? (
92-
remotePeers.size ? (
93-
<Popover content={remoteActions}>{button}</Popover>
94-
) : (
95-
<Tooltip content="Connect to a peer to share">{button}</Tooltip>
96-
)
97-
) : null}
98-
<Tooltip content="Save Image">
99-
<Button
100-
minimal
101-
icon={<Camera />}
102-
onClick={async () => {
103-
const drawingId = getDrawing().id;
104-
const drawingElement = document.querySelector(
105-
"#drawing-" + drawingId,
88+
<div className={styles.networkButtons}>
89+
{syncPeer && <Icon icon={<Changes />} intent="success" />}
90+
{isConnected ? (
91+
remotePeers.size ? (
92+
<Popover content={remoteActions}>{button}</Popover>
93+
) : (
94+
<Tooltip content="Connect to a peer to share">{button}</Tooltip>
95+
)
96+
) : null}
97+
<Tooltip content="Save Image">
98+
<Button
99+
minimal
100+
icon={<Camera />}
101+
onClick={async () => {
102+
const drawingId = getDrawing().id;
103+
const drawingElement = document.querySelector(
104+
"#drawing-" + drawingId,
105+
);
106+
if (drawingElement) {
107+
shareImage(
108+
await domToPng(drawingElement, {
109+
scale: 2,
110+
}),
111+
DEFAULT_FILENAME,
106112
);
107-
if (drawingElement) {
108-
shareImage(
109-
await domToPng(drawingElement, {
110-
scale: 2,
111-
}),
112-
DEFAULT_FILENAME,
113-
);
114-
}
115-
}}
116-
/>
117-
</Tooltip>
118-
<Tooltip content="Redraw all charts">
119-
<Button
120-
minimal
121-
icon={<Refresh />}
122-
onClick={() =>
123-
confirm(
124-
"This will replace everything besides protects and pocket picks!",
125-
) && redrawAllCharts()
126113
}
127-
/>
114+
}}
115+
/>
116+
</Tooltip>
117+
<Tooltip content="Redraw all charts">
118+
<Button
119+
minimal
120+
icon={<Refresh />}
121+
onClick={() =>
122+
confirm(
123+
"This will replace everything besides protects and pocket picks!",
124+
) && redrawAllCharts()
125+
}
126+
/>
127+
</Tooltip>
128+
{process.env.NODE_ENV === "production" ? null : (
129+
<Tooltip content="Cause Error">
130+
<Button minimal icon={<Error />} onClick={showBoundary} />
128131
</Tooltip>
129-
{process.env.NODE_ENV === "production" ? null : (
130-
<Tooltip content="Cause Error">
131-
<Button minimal icon={<Error />} onClick={showBoundary} />
132+
)}
133+
{showLabels && (
134+
<>
135+
<Tooltip content="Add Player">
136+
<Button
137+
minimal
138+
icon={<NewPerson />}
139+
onClick={() => {
140+
updateDrawing((drawing) => {
141+
const next = drawing.players.slice();
142+
next.push("");
143+
return { players: next };
144+
});
145+
}}
146+
/>
147+
</Tooltip>
148+
<Tooltip content="Remove Player" disabled={!hasPlayers}>
149+
<Button
150+
minimal
151+
icon={<BlockedPerson />}
152+
disabled={!hasPlayers}
153+
onClick={() => {
154+
updateDrawing((drawing) => {
155+
const next = drawing.players.slice();
156+
next.pop();
157+
return { players: next };
158+
});
159+
}}
160+
/>
132161
</Tooltip>
133-
)}
134-
{showLabels && (
135-
<>
136-
<Tooltip content="Add Player">
137-
<Button
138-
minimal
139-
icon={<NewPerson />}
140-
onClick={() => {
141-
updateDrawing((drawing) => {
142-
const next = drawing.players.slice();
143-
next.push("");
144-
return { players: next };
145-
});
146-
}}
147-
/>
148-
</Tooltip>
149-
<Tooltip content="Remove Player" disabled={!hasPlayers}>
150-
<Button
151-
minimal
152-
icon={<BlockedPerson />}
153-
disabled={!hasPlayers}
154-
onClick={() => {
155-
updateDrawing((drawing) => {
156-
const next = drawing.players.slice();
157-
next.pop();
158-
return { players: next };
159-
});
160-
}}
161-
/>
162-
</Tooltip>
163-
</>
164-
)}
165-
</div>
166-
</>
162+
</>
163+
)}
164+
</div>
167165
);
168166
}

src/utils/share.ts

+62-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { zeroPad } from ".";
2+
import { EligibleChart } from "../models/Drawing";
13
import { toaster } from "../toaster";
24

35
interface NativeShare {
@@ -15,6 +17,57 @@ interface Download {
1517
type: "download";
1618
}
1719

20+
export function dateForFilename() {
21+
const d = new Date();
22+
return `${d.getFullYear()}${zeroPad(d.getMonth() + 1, 2)}${zeroPad(d.getDay(), 2)}-${zeroPad(d.getHours(), 2)}${zeroPad(d.getMinutes(), 2)}${zeroPad(d.getSeconds(), 2)}`;
23+
}
24+
25+
export async function shareCharts(charts: EligibleChart[]) {
26+
const csvData = [
27+
["name", "diff", "lvl", "sanbaiLvl", "bpm", "artist"],
28+
...charts.map((chart) => {
29+
return [
30+
chart.name,
31+
chart.diffAbbr,
32+
chart.level,
33+
chart.granularLevel,
34+
chart.bpm,
35+
chart.artist,
36+
];
37+
}),
38+
];
39+
40+
const pp = await import("papaparse");
41+
42+
shareData(buildDataUri(pp.unparse(csvData), "text/csv", "url"), {
43+
filename: `ddr-tools-eligible-charts-${dateForFilename()}.csv`,
44+
methods: [
45+
{ type: "nativeShare", allowDesktop: false },
46+
{ type: "download" },
47+
],
48+
});
49+
}
50+
51+
export function buildDataUri(
52+
utf8Payload: string,
53+
mimetype: string,
54+
encoding: "base64" | "url",
55+
) {
56+
let encodedData: string;
57+
let encodingMarker = "";
58+
if (encoding === "base64") {
59+
encodingMarker = ";base64";
60+
const data = new TextEncoder().encode(utf8Payload);
61+
const binString = Array.from(data, (byte) =>
62+
String.fromCodePoint(byte),
63+
).join("");
64+
encodedData = btoa(binString);
65+
} else {
66+
encodedData = encodeURI(utf8Payload);
67+
}
68+
return `data:${mimetype}${encodingMarker},${encodedData}`;
69+
}
70+
1871
type ShareMethod = NativeShare | Clipboard | Download;
1972

2073
export async function shareImage(dataUrl: string, filename: string) {
@@ -51,7 +104,12 @@ export async function shareData(
51104
method.allowDesktop,
52105
);
53106
if (maybePromise) {
54-
return maybePromise;
107+
try {
108+
await maybePromise;
109+
return;
110+
} catch (e) {
111+
console.warn("nativeShare failed", e);
112+
}
55113
}
56114
break;
57115

@@ -69,12 +127,13 @@ export async function shareData(
69127
"copied-data",
70128
);
71129
return;
72-
} catch {
130+
} catch (e) {
131+
console.warn("clipboard share failed", e);
73132
break;
74133
}
75134
case "download":
76135
downloadDataUrl(dataUri, opts.filename);
77-
break;
136+
return;
78137
}
79138
}
80139
}

0 commit comments

Comments
 (0)