├── .github └── ISSUE_TEMPLATE │ ├── bugreport.yml │ └── config.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── biome.json ├── electron ├── main.ts └── preload.ts ├── image ├── features.png ├── help.png └── main.png ├── package-lock.json ├── package.json ├── public ├── css │ └── styles.css ├── icon │ ├── download.svg │ └── x_logo.svg ├── index.html └── js │ ├── core.js │ ├── date-range-handler.js │ ├── network-controls.js │ ├── network-generation.js │ └── network-visualization.js ├── src ├── app.ts ├── controllers │ ├── imageController.ts │ └── metadataController.ts ├── endpoints │ ├── configDirectoryEndpoint.ts │ ├── imageUploadEndpoint.ts │ ├── metadataDateRangeEndpoint.ts │ ├── metadataFileEndpoint.ts │ ├── metadataFilesEndpoint.ts │ ├── metadataFilterEndpoint.ts │ ├── metadataGenerationEndpoint.ts │ ├── metadataStopEndpoint.ts │ └── serverShutdownEndpoint.ts ├── routes │ └── apiRoutes.ts ├── services │ ├── fileStorageService.ts │ ├── imageService.ts │ └── metadataService.ts ├── types.ts └── utils │ ├── errorHandler.ts │ └── pngParser.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bugreport.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve the VRChat Friend Network visualization tool. 3 | labels: ["bug", "triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ### 🐞 Bug reports help improve the VRChat Friend Network visualization tool! 9 | 10 | * ❔ For questions, help, ideas, or feature requests, please use the [Discussions](https://github.com/refiaa/VRChatFriendShipVisualizer/discussions) tab. 11 | 12 | * Before submitting a bug report, please search [existing issues](https://github.com/refiaa/VRChatFriendShipVisualizer/issues) to avoid duplicates. 13 | 14 | * Please provide detailed information to help us understand and resolve the issue efficiently. 15 | - type: input 16 | attributes: 17 | label: VRChat Friend Network Version 18 | description: Which version of the application are you using? Check package.json or git commit hash. 19 | placeholder: "v1.0.0 or commit hash" 20 | validations: 21 | required: true 22 | - type: input 23 | attributes: 24 | label: Node.js Version 25 | description: Output of `node --version` 26 | placeholder: "v16.14.0" 27 | validations: 28 | required: true 29 | - type: input 30 | attributes: 31 | label: Operating System 32 | description: Which OS are you using? 33 | placeholder: "Windows 10, Ubuntu 20.04, macOS Monterey" 34 | validations: 35 | required: true 36 | - type: input 37 | attributes: 38 | label: Browser & Version 39 | description: Which browser and version are you using to view the visualization? 40 | placeholder: "Chrome 120.0.6099.109" 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Reproduction Steps 46 | description: How can we reproduce this issue? Include exact steps. 47 | placeholder: | 48 | 1. Start the server with `npm run dev` 49 | 2. Navigate to 'http://localhost:3000' 50 | 3. Upload images to './img' folder 51 | 4. Click on '...' 52 | 5. Observe error 53 | validations: 54 | required: true 55 | - type: textarea 56 | attributes: 57 | label: Expected Behavior 58 | description: What did you expect to happen? 59 | placeholder: "The graph should display connections between players..." 60 | validations: 61 | required: true 62 | - type: textarea 63 | attributes: 64 | label: Actual Behavior 65 | description: What actually happened? Include any error messages or unexpected behavior. 66 | placeholder: "The graph failed to render and console showed errors..." 67 | validations: 68 | required: true 69 | - type: textarea 70 | attributes: 71 | label: Server Logs 72 | description: Include relevant server logs (from terminal where you run npm run dev) 73 | placeholder: "Paste server logs here..." 74 | validations: 75 | required: false 76 | - type: textarea 77 | attributes: 78 | label: Browser Console Errors 79 | description: Include any relevant browser console errors (F12 > Console) 80 | placeholder: "Paste browser console errors here..." 81 | validations: 82 | required: false 83 | - type: textarea 84 | attributes: 85 | label: Image Processing Details 86 | description: If the issue is related to image processing, provide details about the images 87 | placeholder: | 88 | - Number of images: 89 | - Image format(s): 90 | - Were images taken with VRCX running? 91 | - Screenshot helper status: 92 | validations: 93 | required: false 94 | - type: textarea 95 | attributes: 96 | label: Additional Context 97 | description: Add any other context about the problem here. **Ensure no personal information or VRChat usernames are included.** 98 | validations: 99 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false # Disables the ability to open blank issues 2 | contact_links: 3 | - name: 🤷‍♀️ Questions or need help? 4 | url: https://github.com/refiaa/VRChatFriendShipVisualizer/discussions/categories/questions 5 | about: If you have questions or need help with VRChatFriendShipVisualizer, check existing questions or post a new one here. 6 | 7 | - name: 💬 Discussions 8 | url: https://github.com/refiaa/VRChatFriendShipVisualizer/discussions 9 | about: Join the discussions. Share your experiences, challenges, and workarounds with the VRChatFriendShipVisualizer community. 10 | 11 | - name: 💡 Suggestions / Ideas 12 | url: https://github.com/refiaa/VRChatFriendShipVisualizer/discussions/categories/ideas 13 | about: Have ideas for new features or improvements? Check out existing suggestions or share your own. 14 | 15 | issue_templates: 16 | - name: Bug Report 17 | description: Report a bug to help us improve VRChatFriendShipVisualizer. 18 | template: bugreport.yml 19 | title: "[BUG] " 20 | labels: ["bug", "needs triage"] 21 | 22 | - name: Feature Request 23 | description: Suggest an idea or enhancement for VRChatFriendShipVisualizer. 24 | template: feature_request.yml 25 | title: "[FEATURE] " 26 | labels: ["enhancement", "needs review"] 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff 5 | .idea/**/workspace.xml 6 | .idea/**/tasks.xml 7 | .idea/**/usage.statistics.xml 8 | .idea/**/dictionaries 9 | .idea/**/shelf 10 | 11 | # AWS User-specific 12 | .idea/**/aws.xml 13 | 14 | # Generated files 15 | .idea/**/contentModel.xml 16 | 17 | # Sensitive or high-churn files 18 | .idea/**/dataSources/ 19 | .idea/**/dataSources.ids 20 | .idea/**/dataSources.local.xml 21 | .idea/**/sqlDataSources.xml 22 | .idea/**/dynamic.xml 23 | .idea/**/uiDesigner.xml 24 | .idea/**/dbnavigator.xml 25 | 26 | # Gradle 27 | .idea/**/gradle.xml 28 | .idea/**/libraries 29 | 30 | # Gradle and Maven with auto-import 31 | # When using Gradle or Maven with auto-import, you should exclude module files, 32 | # since they will be recreated, and may cause churn. Uncomment if using 33 | # auto-import. 34 | # .idea/artifacts 35 | # .idea/compiler.xml 36 | # .idea/jarRepositories.xml 37 | # .idea/modules.xml 38 | # .idea/*.iml 39 | # .idea/modules 40 | # *.iml 41 | # *.ipr 42 | 43 | # CMake 44 | cmake-build-*/ 45 | 46 | # Mongo Explorer plugin 47 | .idea/**/mongoSettings.xml 48 | 49 | # File-based project format 50 | *.iws 51 | 52 | # IntelliJ 53 | out/ 54 | 55 | # mpeltonen/sbt-idea plugin 56 | .idea_modules/ 57 | 58 | # JIRA plugin 59 | atlassian-ide-plugin.xml 60 | 61 | # Cursive Clojure plugin 62 | .idea/replstate.xml 63 | 64 | # SonarLint plugin 65 | .idea/sonarlint/ 66 | 67 | # Crashlytics plugin (for Android Studio and IntelliJ) 68 | com_crashlytics_export_strings.xml 69 | crashlytics.properties 70 | crashlytics-build.properties 71 | fabric.properties 72 | 73 | # Editor-based Rest Client 74 | .idea/httpRequests 75 | 76 | # Android studio 3.1+ serialized cache file 77 | .idea/caches/build_file_checksums.ser 78 | 79 | node_modules/ 80 | data/metadata/ 81 | .env 82 | .DS_Store 83 | data/ 84 | img/ 85 | 86 | .idea/ 87 | dist/ 88 | public/uploads -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.1 - 2024-12-12 4 | ### New features 5 | - Basic VRChat friendship visualization functionality 6 | - Metadata generation and processing system 7 | - Network graph visualization (using D3.js) 8 | - Friendship strength-based visualization 9 | - Search and highlight functionality 10 | - SVG export feature 11 | - X(Twitter) share feature 12 | - Directory configuration functionality 13 | 14 | ### Changes 15 | - Migrated JavaScript backend to TypeScript 16 | - Implemented Express.js based RESTful API 17 | 18 | ## 0.0.2 - 2024-12-13 19 | ### New features 20 | - Added placeholder text for initial page load and no data scenarios 21 | - Improved user guidance with welcome message when first loading the page 22 | - Enhanced empty state handling throughout the visualization process 23 | 24 | ### Bugfixes 25 | - Corrected event listener timing for search functionality 26 | 27 | ## 0.0.3 - 2024-12-13 28 | ### New features 29 | - Added date range selection using a slider interface 30 | 31 | ### Changes 32 | - Improved structure of ./public/js directory 33 | 34 | ### Bugfixes 35 | - Modified backend API to enable filter application even when metadata files exist without internal data 36 | 37 | ## 0.0.3.1 - 2024-12-13 38 | ### Bugfixes 39 | - Fixed an issue where slider values would reset after applying date filter 40 | - Fixed an issue where sliders were still movable when start and end months were the same 41 | 42 | ## 0.0.3.2 - 2024-12-18 43 | ### Changes 44 | - Enhanced node filtering logic to better handle circular references 45 | - Modified network visualization Logic for better performance 46 | - Backend / Frontend Structure Refactored 47 | 48 | ### Bugfixes 49 | - Fixed search functionality after recent refactoring 50 | 51 | ## 0.0.3.3 - 2024-12-28 52 | ### Changes 53 | - Modified default image directory path to use user's VRChat folder 54 | 55 | ## 0.0.3.4 - 2024-12-28 56 | ### New features 57 | - Added server shutdown functionality 58 | - Added shutdown server button to UI 59 | - Implemented clean server shutdown endpoint 60 | 61 | --- 62 | 63 | ## 0.1.0 - 2025-02-03 64 | 65 | ### New Features 66 | - **EXE Packaging** 67 | 68 | - **Backend Enhancements** 69 | - Changed the metadata storage directory to the user's Pictures folder under `VRChat\metadata` so that metadata JSON files are generated in a familiar and accessible location (e.g., `C:\Users\{username}\Pictures\VRChat\metadata`). 70 | - Refactored file path and environment variable handling in backend APIs to improve stability and error handling. 71 | 72 | ### Changes 73 | - Optimized the list of files and dependencies included in the final bundle to exclude unnecessary development files and resources. 74 | - Updated NSIS installer configuration to allow users to change the installation directory and to create shortcuts on the desktop and in the Start Menu. 75 | - Refactored metadata directory initialization to correctly handle existing files versus directories, ensuring that the metadata directory is properly created even in a packaged environment. 76 | 77 | ### Bugfixes 78 | - Fixed an issue where the metadata directory initialization would fail with an ENOTDIR error by changing the metadata directory to a more appropriate location (under `Pictures\VRChat\metadata`). 79 | - Addressed minor API and UI issues, including proper handling of environment variables and file paths in the backend. 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Refia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # VRChat Friend Network 4 | 5 | 6 | [![GitHub License](https://img.shields.io/github/license/refiaa/VRChatFriendShipVisualizer?style=flat-round&color=red)](https://github.com/refiaa/VRChatFriendShipVisualizer/blob/master/LICENSE) 7 | [![Downloads](https://img.shields.io/github/downloads/refiaa/VRChatFriendShipVisualizer/total?color=orange)](https://github.com/refiaa/VRChatFriendShipVisualizer/releases/latest) 8 | [![GitHub Stars](https://img.shields.io/github/stars/refiaa/VRChatFriendShipVisualizer?style=flat-round&color=yellow)](https://github.com/refiaa/VRChatFriendShipVisualizer/stargazers) 9 | [![GitHub Forks](https://img.shields.io/github/forks/refiaa/VRChatFriendShipVisualizer?style=flat-round&color=green)](https://github.com/refiaa/VRChatFriendShipVisualizer/network/members) 10 | [![GitHub Closed PRs](https://img.shields.io/github/issues-pr-closed/refiaa/VRChatFriendShipVisualizer?style=flat-round&color=blue)](https://github.com/refiaa/VRChatFriendShipVisualizer/pulls?q=is%3Apr+is%3Aclosed) 11 | [![GitHub Closed Issues](https://img.shields.io/github/issues-closed/refiaa/VRChatFriendShipVisualizer?style=flat-round&color=purple)](https://github.com/refiaa/VRChatFriendShipVisualizer/issues?q=is%3Aissue+is%3Aclosed) 12 | 13 | 14 | ![preview](./image/main.png) 15 | 16 |
17 | 18 | > [!WARNING] 19 | > Since this installer is not signed with a paid software certificate, a warning may appear when launching it. 20 | > If you wish to proceed, click “More info” and then select “Run anyway.” 21 | 22 | 23 | A web-based application that visualizes your VRChat friend network using interactive D3.js graphs. Easily scan your VRChat photos, generate metadata, and explore connections between players through an intuitive and dynamic interface. 24 | 25 | Only photos taken while [**VRCX**](https://github.com/vrcx-team/VRCX) is running are supported. In addition, only photos taken with the `screenshot helper` **enabled** in VRCX can be used. 26 | 27 |
28 | 29 | ![preview](./image/help.png) 30 | 31 |
32 | 33 |
34 | 35 | --- 36 | 37 | - [Features](#features) 38 | - [Installation](#installation) 39 | - [Installation as a Standalone EXE](#installation-as-a-standalone-exe) 40 | - [Installation via Node.js (for dev)](#installation-via-nodejs) 41 | - [Setup](#setup) 42 | - [Usage](#usage) 43 | - [Dependencies](#dependencies) 44 | - [VirusTotal Scan](#virustotal-scan) 45 | - [Changelog](#changelog) 46 | - [License](#license) 47 | 48 | ## Features 49 | 50 | ![features](./image/features.png) 51 | 52 | - **Interactive Network Graph:** Visualize connections between VRChat players with dynamic force-directed graphs. 53 | - **Search Functionality:** Easily search for players by name and highlight their connections. 54 | - **Responsive Design:** Accessible and functional across various screen sizes. 55 | - **Share and Download:** Download your network visualization as an SVG or share directly to Twitter using tmpfiles.org. 56 | - **Date Range-based Data Filter:** Filter your images by date range using an intuitive slider. 57 | - **EXE Installation Package:** (New) Install the application as a standalone Windows program with typical installer features (default installation folder, desktop and start menu shortcuts). 58 | 59 | ## Installation 60 | 61 | 62 | ### Installation as a Standalone EXE 63 | 64 | For users who prefer a traditional desktop installation, a standalone EXE package is available. 65 | 66 | 1. **Download the Installer:** 67 | - Visit the [GitHub Releases](https://github.com/refiaa/VRChatFriendShipVisualizer/releases) page and download the latest installer (EXE). 68 | 69 | 2. **Run the Installer:** 70 | - Double-click the installer. 71 | - Follow the on-screen instructions to install the application (default installation directory is typically `C:\Program Files\VRChatFriendShipVisualizer`). 72 | - During installation, you can change the installation directory if desired. 73 | - The installer will automatically create desktop and Start Menu shortcuts. 74 | 75 | 3. **Run the Application:** 76 | - After installation, launch the app either from the desktop shortcut or from the Start Menu. 77 | - The application will run as a standalone desktop application with an embedded Express server and all features intact. 78 | 79 | 80 | ### Installation via Node.js 81 | 82 | #### Prerequisites 83 | 84 | Before you begin, ensure you have met the following requirements: 85 | 86 | - **Node.js:** Install Node.js (v14 or later) from [Node.js official website](https://nodejs.org/). 87 | - **npm:** Node.js installation includes npm. Verify installation by running: 88 | ```bash 89 | node -v 90 | npm -v 91 | ``` 92 | 93 | 1. **Clone the Repository:** 94 | ```bash 95 | git clone https://github.com/refiaa/VRChatFriendShipVisualizer.git 96 | ``` 97 | 98 | 2. **Navigate to the Project Directory:** 99 | ```bash 100 | cd VRChatFriendShipVisualizer 101 | ``` 102 | 103 | 3. **Install Dependencies:** 104 | ```bash 105 | npm install 106 | ``` 107 | 108 | 4. **Run the Application:** 109 | ```bash 110 | npm run dev 111 | ``` 112 | - The development server will start on the configured port (default is 3000). 113 | - Open your browser and navigate to [http://localhost:3000](http://localhost:3000/) to view the application. 114 | 115 | ## Setup 116 | 117 | 1. **Prepare VRChat Images:** 118 | - By default, the application uses your VRChat image folder located at: 119 | ``` 120 | C:\Users\{YourUsername}\Pictures\VRChat 121 | ``` 122 | - To change the directory, use the directory configuration option in the app UI. 123 | 124 | 2. **Metadata Generation:** 125 | - Metadata is generated from your VRChat photos and stored in the `metadata` folder located inside your VRChat folder: 126 | ``` 127 | C:\Users\{YourUsername}\Pictures\VRChat\metadata 128 | ``` 129 | 130 | ## Usage 131 | 132 | 1. **Start the Application:** 133 | - For Node.js version, run `npm run dev` and navigate to [http://localhost:3000](http://localhost:3000/). 134 | - For the EXE version, simply launch the installed application. 135 | 136 | 2. **Update Visualization:** 137 | - Place your VRChat photos in the designated folder or configure a new directory. 138 | - Click on **Update Visualization** to generate the friend network graph. 139 | 140 | 3. **Additional Features:** 141 | - Use the search functionality to highlight specific nodes. 142 | - Filter data by date range using the slider. 143 | - Export the visualization as an SVG or share via Twitter. 144 | 145 | ## Dependencies 146 | 147 | - **D3.js:** Used for dynamic, interactive network graph visualizations. 148 | - **Express.js:** Provides a RESTful API and serves the web application. 149 | - **Electron:** Packages the application as a standalone desktop app. 150 | - **Additional Libraries:** form-data, fs-extra, node-fetch, and others as listed in package.json. 151 | 152 | ## VirusTotal Scan 153 | 154 | This executable package has been scanned on [VirusTotal](https://www.virustotal.com/gui/file/f6864c0f5ca58c3448dc0800209a7fc2f6244e887dceecbffb27bec97861eb08) and is reported clean by most antivirus engines. Minor detections (e.g., Bkav Pro) are known false positives common with Electron applications. 155 | 156 | 157 | ## Changelog 158 | 159 | See [CHANGELOG.md](CHANGELOG.md) for a list of notable changes and updates. 160 | 161 | ## License 162 | 163 | This project is licensed under the [MIT License](LICENSE). 164 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1 -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "indentStyle": "space", 6 | "indentWidth": 2, 7 | "lineWidth": 100 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "recommended": true, 13 | "complexity": { 14 | "all": true 15 | }, 16 | "style": { 17 | "all": true, 18 | "useNamingConvention": { 19 | "level": "warn", 20 | "options": { 21 | "strictCase": false 22 | } 23 | } 24 | }, 25 | "suspicious": { 26 | "noExplicitAny": { 27 | "level": "off" 28 | } 29 | } 30 | } 31 | }, 32 | "organizeImports": { 33 | "enabled": true 34 | }, 35 | "files": { 36 | "ignore": ["node_modules/", "dist/", "public/"] 37 | }, 38 | "vcs": { 39 | "enabled": true, 40 | "clientKind": "git", 41 | "useIgnoreFile": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import { app as electronApp, BrowserWindow } from 'electron'; 2 | import * as path from 'path'; 3 | import { server } from '../src/app'; 4 | 5 | let mainWindow: BrowserWindow | null = null; 6 | 7 | function createMainWindow(): void { 8 | mainWindow = new BrowserWindow({ 9 | width: 1200, 10 | height: 800, 11 | webPreferences: { 12 | preload: path.join(__dirname, 'preload.js'), 13 | contextIsolation: true, 14 | nodeIntegration: false 15 | } 16 | }); 17 | 18 | getServerPort() 19 | .then((port) => { 20 | mainWindow!.loadURL(`http://localhost:${port}`); 21 | }) 22 | .catch((error) => { 23 | console.error("Failed to get server port:", error); 24 | }); 25 | 26 | mainWindow.on('closed', () => { 27 | mainWindow = null; 28 | }); 29 | } 30 | 31 | function getServerPort(): Promise { 32 | return new Promise((resolve, reject) => { 33 | if (server.listening) { 34 | const address = server.address(); 35 | if (address && typeof address === "object") { 36 | resolve(address.port); 37 | } else { 38 | reject(new Error("Server address is not an object")); 39 | } 40 | } else { 41 | server.on("listening", () => { 42 | const address = server.address(); 43 | if (address && typeof address === "object") { 44 | resolve(address.port); 45 | } else { 46 | reject(new Error("Server address is not an object")); 47 | } 48 | }); 49 | } 50 | }); 51 | } 52 | 53 | electronApp.whenReady().then(() => { 54 | createMainWindow(); 55 | electronApp.on('activate', () => { 56 | if (BrowserWindow.getAllWindows().length === 0) { 57 | createMainWindow(); 58 | } 59 | }); 60 | }); 61 | 62 | electronApp.on('window-all-closed', () => { 63 | if (process.platform !== 'darwin') { 64 | server.close(() => { 65 | electronApp.quit(); 66 | }); 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron'; 2 | 3 | contextBridge.exposeInMainWorld('electronAPI', {}); 4 | -------------------------------------------------------------------------------- /image/features.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/refiaa/VRChatFriendShipVisualizer/49c0141c68a57dee2b58296af0da36973ebd8651/image/features.png -------------------------------------------------------------------------------- /image/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/refiaa/VRChatFriendShipVisualizer/49c0141c68a57dee2b58296af0da36973ebd8651/image/help.png -------------------------------------------------------------------------------- /image/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/refiaa/VRChatFriendShipVisualizer/49c0141c68a57dee2b58296af0da36973ebd8651/image/main.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vrchatfriendshipvisualizer", 3 | "version": "0.1.0", 4 | "description": "VRChat friendship network visualization tool", 5 | "main": "dist/electron/main.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "dev": "nodemon --exec ts-node src/app.ts --ignore 'data/*' --ignore 'public/*'", 9 | "build": "tsc && npm run copy-public && npm run copy-version", 10 | "copy-public": "copyfiles -u 1 public/**/* dist/public", 11 | "copy-version": "copyfiles -u 0 VERSION dist/", 12 | "format": "npx biome format --write .", 13 | "lint": "npx biome lint .", 14 | "electron:build": "cross-env WIN_CSC_LINK=\"\" CSC_LINK=\"\" CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder" 15 | }, 16 | "keywords": [ 17 | "vrchat", 18 | "network", 19 | "visualization" 20 | ], 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "dotenv": "^16.4.7", 25 | "express": "^4.21.2", 26 | "form-data": "^4.0.1", 27 | "fs-extra": "^11.2.0", 28 | "node-fetch": "^2.7.0" 29 | }, 30 | "devDependencies": { 31 | "@biomejs/biome": "^1.9.4", 32 | "@types/d3": "^7.4.3", 33 | "@types/electron": "^1.6.12", 34 | "@types/express": "^5.0.0", 35 | "@types/form-data": "^2.5.2", 36 | "@types/fs-extra": "^11.0.4", 37 | "@types/jest": "^29.5.14", 38 | "@types/node": "^22.10.2", 39 | "@types/node-fetch": "^2.6.12", 40 | "concurrently": "^9.1.2", 41 | "copyfiles": "^2.4.1", 42 | "cross-env": "^7.0.3", 43 | "electron": "^34.0.2", 44 | "electron-builder": "^25.1.8", 45 | "globals": "^15.13.0", 46 | "nodemon": "^3.1.7", 47 | "ts-node": "^10.9.2", 48 | "typescript": "^5.7.2" 49 | }, 50 | "nodemonConfig": { 51 | "ignore": [ 52 | "data/*", 53 | "public/*", 54 | "*.json" 55 | ], 56 | "watch": [ 57 | "src/", 58 | "electron/" 59 | ], 60 | "ext": "js,ts" 61 | }, 62 | "build": { 63 | "asar": true, 64 | "appId": "refiaa.vrchatfriendshipvisualizer", 65 | "files": [ 66 | "dist/**/*", 67 | "public/**/*", 68 | "node_modules/**/*", 69 | "src/**/*" 70 | ], 71 | "directories": { 72 | "buildResources": "build" 73 | }, 74 | "extraResources": [ 75 | "VERSION", 76 | "LICENSE" 77 | ], 78 | "win": { 79 | "certificateFile": null, 80 | "certificateSubjectName": null, 81 | "signAndEditExecutable": false 82 | }, 83 | "nsis": { 84 | "oneClick": false, 85 | "allowElevation": true, 86 | "allowToChangeInstallationDirectory": true, 87 | "perMachine": true, 88 | "createDesktopShortcut": true, 89 | "createStartMenuShortcut": true, 90 | "shortcutName": "VRChatFriendShipVisualizer" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | body { 3 | font-family: Arial, sans-serif; 4 | margin: 0; 5 | padding: 20px; 6 | background-color: #f5f5f5; 7 | } 8 | 9 | .container { 10 | max-width: 1200px; 11 | margin: 0 auto; 12 | background-color: white; 13 | padding: 20px; 14 | border-radius: 8px; 15 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 16 | } 17 | 18 | /* Graph container */ 19 | #graph { 20 | width: 100%; 21 | height: 800px; 22 | border: 1px solid #ddd; 23 | border-radius: 4px; 24 | background-color: white; 25 | position: relative; 26 | } 27 | 28 | /* Graph elements */ 29 | .node text { 30 | font-size: 12px; 31 | fill: #333; 32 | } 33 | 34 | .link { 35 | stroke: #999; 36 | stroke-opacity: 0.6; 37 | } 38 | 39 | /* Controls */ 40 | .controls { 41 | margin-bottom: 20px; 42 | } 43 | 44 | button { 45 | padding: 8px 16px; 46 | margin-right: 10px; 47 | background-color: #4CAF50; 48 | color: white; 49 | border: none; 50 | border-radius: 4px; 51 | cursor: pointer; 52 | } 53 | 54 | button:hover { 55 | background-color: #45a049; 56 | } 57 | 58 | button:disabled { 59 | background-color: #cccccc; 60 | cursor: not-allowed; 61 | } 62 | 63 | /* Tooltip */ 64 | .tooltip { 65 | position: absolute; 66 | background-color: rgba(0, 0, 0, 0.8); 67 | color: white; 68 | padding: 8px; 69 | border-radius: 4px; 70 | font-size: 12px; 71 | pointer-events: none; 72 | z-index: 1000; 73 | } 74 | 75 | /* Loading overlay */ 76 | .loading-overlay { 77 | position: absolute; 78 | top: 0; 79 | left: 0; 80 | right: 0; 81 | bottom: 0; 82 | background-color: rgba(255, 255, 255, 0.9); 83 | display: flex; 84 | flex-direction: column; 85 | align-items: center; 86 | justify-content: center; 87 | z-index: 1000; 88 | } 89 | 90 | .spinner { 91 | width: 50px; 92 | height: 50px; 93 | border: 5px solid #f3f3f3; 94 | border-top: 5px solid #3498db; 95 | border-radius: 50%; 96 | animation: spin 1s linear infinite; 97 | margin-bottom: 10px; 98 | } 99 | 100 | @keyframes spin { 101 | 0% { transform: rotate(0deg); } 102 | 100% { transform: rotate(360deg); } 103 | } 104 | 105 | /* Progress and status */ 106 | .progress-status { 107 | margin-top: 10px; 108 | text-align: center; 109 | color: #333; 110 | font-weight: bold; 111 | white-space: nowrap; 112 | overflow: hidden; 113 | text-overflow: ellipsis; 114 | max-width: 100%; 115 | padding: 0 20px; 116 | } 117 | 118 | #result { 119 | margin-top: 20px; 120 | padding: 15px; 121 | background-color: #f8f9fa; 122 | border-radius: 4px; 123 | border: 1px solid #dee2e6; 124 | } 125 | 126 | /* Debug section */ 127 | #debug { 128 | margin-top: 10px; 129 | padding: 10px; 130 | background-color: #f8f9fa; 131 | border-radius: 4px; 132 | font-family: monospace; 133 | font-size: 12px; 134 | } 135 | 136 | /* Search functionality */ 137 | .search-container { 138 | margin-bottom: 20px; 139 | display: flex; 140 | align-items: center; 141 | gap: 10px; 142 | } 143 | 144 | .search-input { 145 | padding: 8px 12px; 146 | border: 1px solid #ddd; 147 | border-radius: 4px; 148 | font-size: 14px; 149 | flex-grow: 1; 150 | max-width: 300px; 151 | } 152 | 153 | .search-results { 154 | position: absolute; 155 | background: white; 156 | border: 1px solid #ddd; 157 | border-radius: 4px; 158 | max-height: 200px; 159 | overflow-y: auto; 160 | width: 300px; 161 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 162 | z-index: 1000; 163 | } 164 | 165 | .search-result-item { 166 | padding: 8px 12px; 167 | cursor: pointer; 168 | } 169 | 170 | .search-result-item:hover { 171 | background-color: #f5f5f5; 172 | } 173 | 174 | /* Directory input */ 175 | .directory-container { 176 | margin-bottom: 20px; 177 | display: flex; 178 | align-items: center; 179 | gap: 10px; 180 | } 181 | 182 | .directory-input { 183 | padding: 8px 12px; 184 | border: 1px solid #ddd; 185 | border-radius: 4px; 186 | font-size: 14px; 187 | flex-grow: 1; 188 | max-width: 500px; 189 | } 190 | 191 | /* Status indicators */ 192 | .directory-status { 193 | margin-top: 5px; 194 | font-size: 12px; 195 | } 196 | 197 | .directory-status.success { 198 | color: #28a745; 199 | } 200 | 201 | .directory-status.error { 202 | color: #dc3545; 203 | } 204 | 205 | .success { 206 | color: #28a745; 207 | } 208 | 209 | .error { 210 | color: #dc3545; 211 | } 212 | 213 | /* Node highlighting */ 214 | .highlighted-node circle { 215 | stroke: #ff0000; 216 | stroke-width: 3px; 217 | } 218 | 219 | .dimmed { 220 | opacity: 0.2; 221 | } 222 | 223 | /* Action buttons */ 224 | .action-buttons { 225 | display: flex; 226 | justify-content: space-between; 227 | align-items: center; 228 | margin-top: 10px; 229 | } 230 | 231 | .main-buttons { 232 | display: flex; 233 | gap: 10px; 234 | } 235 | 236 | #stopButton { 237 | background-color: #dc3545; 238 | } 239 | 240 | #stopButton:hover { 241 | background-color: #c82333; 242 | } 243 | 244 | #stopButton:disabled { 245 | background-color: #dc354580; 246 | } 247 | 248 | /* Share buttons */ 249 | .share-button { 250 | padding: 8px; 251 | background: transparent; 252 | border: none; 253 | cursor: pointer; 254 | } 255 | 256 | .share-buttons { 257 | display: flex; 258 | gap: 8px; 259 | align-items: center; 260 | } 261 | 262 | .x-icon { 263 | width: 40px; 264 | height: 40px; 265 | } 266 | 267 | .icon { 268 | width: 30px; 269 | height: 30px; 270 | } 271 | 272 | /* Loading spinner */ 273 | .loading-spinner { 274 | display: inline-block; 275 | width: 20px; 276 | height: 20px; 277 | border: 2px solid rgba(0,0,0,0.1); 278 | border-radius: 50%; 279 | border-top-color: #000; 280 | animation: spin 1s linear infinite; 281 | } 282 | 283 | .version { 284 | font-size: 0.5em; 285 | color: #666; 286 | font-weight: normal; 287 | margin-left: 10px; 288 | vertical-align: middle; 289 | } 290 | 291 | .no-data-placeholder text { 292 | font-family: Arial, sans-serif; 293 | } 294 | 295 | .no-data-placeholder .main-message { 296 | font-size: 24px; 297 | fill: #6c757d; 298 | font-weight: bold; 299 | } 300 | 301 | .no-data-placeholder .sub-message { 302 | font-size: 16px; 303 | fill: #6c757d; 304 | } 305 | 306 | .no-data-placeholder rect { 307 | fill: #f8f9fa; 308 | } 309 | 310 | .collapsible-container { 311 | margin-top: 10px; 312 | border: 1px solid #ddd; 313 | border-radius: 4px; 314 | } 315 | 316 | .collapsible-button { 317 | width: 100%; 318 | text-align: left; 319 | padding: 10px; 320 | background-color: #f8f9fa; 321 | border: none; 322 | color: #333; 323 | cursor: pointer; 324 | display: flex; 325 | justify-content: space-between; 326 | align-items: center; 327 | } 328 | 329 | .collapsible-button:hover { 330 | background-color: #e9ecef; 331 | } 332 | 333 | .collapsible-content { 334 | max-height: 0; 335 | transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1); 336 | } 337 | 338 | .collapsible-content.active { 339 | max-height: 200px; 340 | border-top: 1px solid #ddd; 341 | } 342 | 343 | .date-slider-container { 344 | padding: 20px; 345 | position: relative; 346 | } 347 | 348 | .date-range-labels { 349 | display: flex; 350 | justify-content: space-between; 351 | margin-bottom: 15px; 352 | color: #333; 353 | font-size: 14px; 354 | font-weight: 500; 355 | padding: 0 10px; 356 | } 357 | 358 | .date-range-labels span { 359 | transition: color 0.2s ease; 360 | } 361 | 362 | .date-range-labels span.active { 363 | color: #4CAF50; 364 | font-weight: 600; 365 | } 366 | 367 | .slider-track { 368 | left: 0; 369 | right: 0; 370 | transform: translateY(-50%); 371 | height: 4px; 372 | width: 100%; 373 | background-color: #ddd; 374 | border-radius: 2px; 375 | } 376 | 377 | .double-range-slider { 378 | position: relative; 379 | height: 40px; 380 | padding: 0 10px; 381 | } 382 | 383 | .double-range-slider input[type="range"] { 384 | -webkit-appearance: none; 385 | appearance: none; 386 | position: absolute; 387 | top: 50%; 388 | transform: translateY(-50%); 389 | left: 0; 390 | width: 100%; 391 | height: 4px; 392 | background: none; 393 | pointer-events: none; 394 | margin: 0; 395 | } 396 | 397 | .double-range-slider input[type="range"]::-webkit-slider-thumb { 398 | -webkit-appearance: none; 399 | appearance: none; 400 | width: 0; 401 | height: 0; 402 | background: transparent; 403 | border-left: 8px solid transparent; 404 | border-right: 8px solid transparent; 405 | border-bottom: 12px solid #4CAF50; 406 | cursor: pointer; 407 | pointer-events: auto; 408 | margin-top: -12px; 409 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 410 | position: relative; 411 | filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2)); 412 | } 413 | 414 | .double-range-slider input[type="range"]::-webkit-slider-thumb:hover { 415 | transform: scale(1.15) translateY(-2px); 416 | border-bottom: 12px solid #45a049; 417 | filter: drop-shadow(0 3px 4px rgba(0,0,0,0.25)); 418 | } 419 | 420 | .double-range-slider input[type="range"]:active::-webkit-slider-thumb { 421 | transform: scale(0.95) translateY(1px); 422 | filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); 423 | } 424 | 425 | .double-range-slider input[type="range"]::-moz-range-thumb { 426 | width: 0; 427 | height: 0; 428 | background: transparent; 429 | border-left: 8px solid transparent; 430 | border-right: 8px solid transparent; 431 | border-bottom: 12px solid #4CAF50; 432 | cursor: pointer; 433 | pointer-events: auto; 434 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 435 | filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2)); 436 | } 437 | 438 | .double-range-slider input[type="range"]::-moz-range-thumb:hover { 439 | transform: scale(1.15) translateY(-2px); 440 | border-bottom: 12px solid #45a049; 441 | filter: drop-shadow(0 3px 4px rgba(0,0,0,0.25)); 442 | } 443 | 444 | .double-range-slider input[type="range"]:active::-moz-range-thumb { 445 | transform: scale(0.95) translateY(1px); 446 | filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); 447 | } 448 | 449 | .date-filter-actions { 450 | display: flex; 451 | justify-content: flex-end; 452 | margin-top: 10px; 453 | } 454 | 455 | .date-filter-actions button { 456 | padding: 5px 15px; 457 | } 458 | 459 | .double-range-slider input[type="range"]:disabled { 460 | cursor: not-allowed; 461 | opacity: 0.5; 462 | } 463 | 464 | .double-range-slider input[type="range"]:disabled::-webkit-slider-thumb { 465 | border-bottom-color: #cccccc; 466 | cursor: not-allowed; 467 | } 468 | 469 | .double-range-slider input[type="range"]:disabled::-moz-range-thumb { 470 | border-bottom-color: #cccccc; 471 | cursor: not-allowed; 472 | } 473 | 474 | .shutdown-button { 475 | background-color: #ff4444; 476 | color: white; 477 | } 478 | 479 | .shutdown-button:hover { 480 | background-color: #cc0000; 481 | } -------------------------------------------------------------------------------- /public/icon/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/icon/x_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | VRChat Friend Network 5 | 6 | 7 | 8 | 9 |
10 |

VRChat Friend Network

11 |
12 |
13 | 17 | 18 |
19 |
20 | 25 | 26 |
27 | 28 |
29 |
30 | 31 | 32 | 33 |
34 | 42 |
43 | 44 |
45 | 46 |
47 |
48 |
49 | Start Date 50 | End Date 51 |
52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 |
63 |
64 | 65 |
66 | 71 |
72 |
73 |
74 |
75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/js/core.js: -------------------------------------------------------------------------------- 1 | let currentNodes; 2 | let currentLinks; 3 | let currentEventSource = null; 4 | let zoom; 5 | let width; 6 | let height; 7 | let searchTimeout; 8 | 9 | document.addEventListener('DOMContentLoaded', function() { 10 | showPlaceholder('VRChat Friend Network Analysis', 'Click "Update Visualization" to start'); 11 | 12 | document.getElementById('searchInput').addEventListener('input', function(e) { 13 | const searchText = e.target.value.toLowerCase(); 14 | clearTimeout(searchTimeout); 15 | 16 | searchTimeout = setTimeout(() => { 17 | if (!searchText) { 18 | clearSearch(); 19 | return; 20 | } 21 | 22 | const matches = currentNodes.filter(node => 23 | node.name.toLowerCase().includes(searchText) 24 | ); 25 | 26 | showSearchResults(matches); 27 | }, 300); 28 | }); 29 | 30 | const collapsible = document.querySelector('.collapsible-button'); 31 | const content = document.querySelector('.collapsible-content'); 32 | 33 | collapsible?.addEventListener('click', function() { 34 | this.classList.toggle('active'); 35 | content.classList.toggle('active'); 36 | 37 | const arrow = this.textContent.includes('▼') ? '▲' : '▼'; 38 | this.textContent = `Data Filter ${arrow}`; 39 | }); 40 | 41 | fetch('/api/version') 42 | .then(response => response.json()) 43 | .then(data => { 44 | const versionElement = document.getElementById('version'); 45 | if (versionElement) { 46 | versionElement.textContent = `v ${data.version}`; 47 | } 48 | }) 49 | .catch(error => { 50 | console.error('Failed to load version:', error); 51 | const versionElement = document.getElementById('version'); 52 | if (versionElement) { 53 | versionElement.textContent = ''; 54 | } 55 | }); 56 | }); 57 | 58 | function showPlaceholder(title, subtitle) { 59 | const graphDiv = document.getElementById('graph'); 60 | graphDiv.innerHTML = ` 61 |
62 |

${title}

63 |

${subtitle}

64 |
65 | `; 66 | } 67 | 68 | function clearSearch() { 69 | document.getElementById('searchInput').value = ''; 70 | document.getElementById('searchResults').style.display = 'none'; 71 | 72 | d3.selectAll('.node') 73 | .classed('highlighted-node', false) 74 | .classed('dimmed', false); 75 | 76 | d3.selectAll('.link') 77 | .classed('dimmed', false); 78 | } 79 | 80 | async function shutdownServer() { 81 | if (confirm('Are you sure you want to shutdown the server?')) { 82 | try { 83 | const response = await fetch('/api/server/shutdown', { 84 | method: 'POST', 85 | }); 86 | const data = await response.json(); 87 | if (data.success) { 88 | alert('Server is shutting down...'); 89 | } 90 | } catch (error) { 91 | console.error('Failed to shutdown server:', error); 92 | alert('Failed to shutdown server'); 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /public/js/date-range-handler.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const startDateSlider = document.getElementById('startDateSlider'); 3 | const endDateSlider = document.getElementById('endDateSlider'); 4 | 5 | startDateSlider?.addEventListener('input', function(e) { 6 | if (window.dateRange && window.dateRange.totalMonths === 0) { 7 | this.value = 0; 8 | return; 9 | } 10 | 11 | const startVal = parseInt(this.value); 12 | const maxLimit = parseInt(endDateSlider.max) - 1; 13 | 14 | if (startVal > maxLimit) { 15 | this.value = maxLimit; 16 | return; 17 | } 18 | 19 | const endVal = parseInt(endDateSlider.value); 20 | if (startVal >= endVal) { 21 | endDateSlider.value = Math.min(maxLimit + 1, startVal + 1); 22 | } 23 | 24 | updateSliderTrack(); 25 | updateSliderState(); 26 | }); 27 | 28 | endDateSlider?.addEventListener('input', function(e) { 29 | if (window.dateRange && window.dateRange.totalMonths === 0) { 30 | this.value = 0; 31 | return; 32 | } 33 | 34 | const endVal = parseInt(this.value); 35 | const minLimit = 1; 36 | 37 | if (endVal < minLimit) { 38 | this.value = minLimit; 39 | return; 40 | } 41 | 42 | const startVal = parseInt(startDateSlider.value); 43 | if (endVal <= startVal) { 44 | startDateSlider.value = Math.max(0, endVal - 1); 45 | } 46 | 47 | updateSliderTrack(); 48 | updateSliderState(); 49 | }); 50 | 51 | startDateSlider?.addEventListener('focus', updateSliderTrack); 52 | endDateSlider?.addEventListener('focus', updateSliderTrack); 53 | startDateSlider?.addEventListener('blur', updateSliderTrack); 54 | endDateSlider?.addEventListener('blur', updateSliderTrack); 55 | 56 | if (startDateSlider && endDateSlider) { 57 | updateSliderTrack(); 58 | } 59 | 60 | document.getElementById('applyDateFilter')?.addEventListener('click', async function() { 61 | const startDate = document.getElementById('startDate').textContent; 62 | const endDate = document.getElementById('endDate').textContent; 63 | const loadingOverlay = document.getElementById('loadingOverlay'); 64 | const progressStatus = document.getElementById('progressStatus'); 65 | 66 | const startSliderValue = document.getElementById('startDateSlider').value; 67 | const endSliderValue = document.getElementById('endDateSlider').value; 68 | 69 | try { 70 | loadingOverlay.style.display = 'flex'; 71 | progressStatus.textContent = 'Filtering data...'; 72 | 73 | const response = await fetch('/api/metadata/filter', { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json' 77 | }, 78 | body: JSON.stringify({ 79 | startDate: startDate, 80 | endDate: endDate 81 | }) 82 | }); 83 | 84 | if (!response.ok) { 85 | throw new Error('Failed to filter metadata'); 86 | } 87 | 88 | const filteredFiles = await response.json(); 89 | progressStatus.textContent = 'Loading filtered data...'; 90 | 91 | const allMetadata = []; 92 | for (const file of filteredFiles) { 93 | try { 94 | const res = await fetch(`/api/metadata/file/${file}`); 95 | if (res.ok) { 96 | const data = await res.json(); 97 | allMetadata.push(data); 98 | } 99 | } catch (error) { 100 | console.error(`Error loading filtered metadata: ${file}`, error); 101 | } 102 | } 103 | 104 | progressStatus.textContent = 'Updating visualization...'; 105 | d3.select('#graph svg').remove(); 106 | await visualizeNetworkData(allMetadata); 107 | 108 | document.getElementById('startDateSlider').value = startSliderValue; 109 | document.getElementById('endDateSlider').value = endSliderValue; 110 | updateSliderTrack(); 111 | 112 | document.getElementById('applyDateFilter').disabled = false; 113 | 114 | } catch (error) { 115 | console.error('Error applying date filter:', error); 116 | progressStatus.textContent = 'Error filtering data'; 117 | document.getElementById('applyDateFilter').disabled = false; 118 | } finally { 119 | loadingOverlay.style.display = 'none'; 120 | progressStatus.textContent = ''; 121 | } 122 | }); 123 | }); 124 | 125 | async function updateDateRange() { 126 | try { 127 | const response = await fetch('/api/metadata/date-range'); 128 | const data = await response.json(); 129 | 130 | const startDateLabel = document.getElementById('startDate'); 131 | const endDateLabel = document.getElementById('endDate'); 132 | const applyDateFilter = document.getElementById('applyDateFilter'); 133 | const startDateSlider = document.getElementById('startDateSlider'); 134 | const endDateSlider = document.getElementById('endDateSlider'); 135 | 136 | if (!data.exists) { 137 | startDateLabel.textContent = 'No Metadata'; 138 | endDateLabel.textContent = 'No Metadata'; 139 | applyDateFilter.disabled = true; 140 | startDateSlider.disabled = true; 141 | endDateSlider.disabled = true; 142 | return; 143 | } 144 | 145 | if (currentNodes && currentLinks) { 146 | applyDateFilter.disabled = false; 147 | startDateSlider.disabled = false; 148 | endDateSlider.disabled = false; 149 | } 150 | 151 | if (data.exists && !data.hasFiles) { 152 | startDateLabel.textContent = 'No Data'; 153 | endDateLabel.textContent = 'No Data'; 154 | if (!currentNodes || !currentLinks) { 155 | applyDateFilter.disabled = true; 156 | startDateSlider.disabled = true; 157 | endDateSlider.disabled = true; 158 | } 159 | return; 160 | } 161 | 162 | if (data.exists && data.hasFiles && !data.hasValidDates) { 163 | startDateLabel.textContent = 'Invalid Dates'; 164 | endDateLabel.textContent = 'Invalid Dates'; 165 | if (!currentNodes || !currentLinks) { 166 | applyDateFilter.disabled = true; 167 | startDateSlider.disabled = true; 168 | endDateSlider.disabled = true; 169 | } 170 | return; 171 | } 172 | 173 | if (data.start && data.end) { 174 | const startDate = new Date(data.start + '-01'); 175 | const endDate = new Date(data.end + '-01'); 176 | 177 | const totalMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 178 | + (endDate.getMonth() - startDate.getMonth()); 179 | 180 | startDateSlider.min = 0; 181 | startDateSlider.max = totalMonths; 182 | startDateSlider.value = 0; 183 | 184 | endDateSlider.min = 0; 185 | endDateSlider.max = totalMonths; 186 | endDateSlider.value = totalMonths; 187 | 188 | startDateLabel.textContent = data.start; 189 | endDateLabel.textContent = data.end; 190 | 191 | window.dateRange = { 192 | start: startDate, 193 | end: endDate, 194 | totalMonths: totalMonths 195 | }; 196 | 197 | updateSliderTrack(); 198 | updateSliderState(); 199 | } 200 | } catch (error) { 201 | console.error('Error fetching date range:', error); 202 | const startDateLabel = document.getElementById('startDate'); 203 | const endDateLabel = document.getElementById('endDate'); 204 | const applyDateFilter = document.getElementById('applyDateFilter'); 205 | const startDateSlider = document.getElementById('startDateSlider'); 206 | const endDateSlider = document.getElementById('endDateSlider'); 207 | 208 | startDateLabel.textContent = 'Error'; 209 | endDateLabel.textContent = 'Error'; 210 | if (!currentNodes || !currentLinks) { 211 | applyDateFilter.disabled = true; 212 | startDateSlider.disabled = true; 213 | endDateSlider.disabled = true; 214 | } 215 | } 216 | } 217 | 218 | function calculateDate(value) { 219 | if (!window.dateRange) { 220 | return 'No Data'; 221 | } 222 | 223 | const { start, end, totalMonths } = window.dateRange; 224 | 225 | if (totalMonths === 0) { 226 | const year = start.getFullYear(); 227 | const month = String(start.getMonth() + 1).padStart(2, '0'); 228 | return `${year}-${month}`; 229 | } 230 | 231 | const monthsToAdd = Math.round((value / totalMonths) * totalMonths); 232 | 233 | const date = new Date(start); 234 | date.setMonth(start.getMonth() + monthsToAdd); 235 | 236 | const year = date.getFullYear(); 237 | const month = String(date.getMonth() + 1).padStart(2, '0'); 238 | 239 | return `${year}-${month}`; 240 | } 241 | 242 | function updateSliderTrack() { 243 | const startDateSlider = document.getElementById('startDateSlider'); 244 | const endDateSlider = document.getElementById('endDateSlider'); 245 | const startDateLabel = document.getElementById('startDate'); 246 | const endDateLabel = document.getElementById('endDate'); 247 | 248 | if (!startDateSlider || !endDateSlider) return; 249 | 250 | const startVal = parseInt(startDateSlider.value); 251 | const endVal = parseInt(endDateSlider.value); 252 | const max = parseInt(endDateSlider.max); 253 | 254 | const startPercent = (startVal / max) * 100; 255 | const endPercent = (endVal / max) * 100; 256 | 257 | const track = document.querySelector('.slider-track'); 258 | if (track) { 259 | track.style.background = 260 | `linear-gradient(to right, 261 | #ddd 0%, 262 | #ddd ${startPercent}%, 263 | #4CAF50 ${startPercent}%, 264 | #4CAF50 ${endPercent}%, 265 | #ddd ${endPercent}%, 266 | #ddd 100% 267 | )`; 268 | } 269 | 270 | if (startDateLabel && endDateLabel) { 271 | startDateLabel.textContent = calculateDate(startVal); 272 | endDateLabel.textContent = calculateDate(endVal); 273 | 274 | startDateLabel.classList.remove('active'); 275 | endDateLabel.classList.remove('active'); 276 | if (document.activeElement === startDateSlider) { 277 | startDateLabel.classList.add('active'); 278 | } else if (document.activeElement === endDateSlider) { 279 | endDateLabel.classList.add('active'); 280 | } 281 | } 282 | } 283 | 284 | function areDatesEqual() { 285 | const startDateLabel = document.getElementById('startDate'); 286 | const endDateLabel = document.getElementById('endDate'); 287 | return startDateLabel.textContent === endDateLabel.textContent; 288 | } 289 | 290 | function updateSliderState() { 291 | const isEqual = areDatesEqual(); 292 | const startDateSlider = document.getElementById('startDateSlider'); 293 | const endDateSlider = document.getElementById('endDateSlider'); 294 | 295 | if (!startDateSlider || !endDateSlider) return; 296 | 297 | startDateSlider.disabled = isEqual; 298 | endDateSlider.disabled = isEqual; 299 | 300 | if (isEqual) { 301 | startDateSlider.style.opacity = '0.5'; 302 | endDateSlider.style.opacity = '0.5'; 303 | } else { 304 | startDateSlider.style.opacity = '1'; 305 | endDateSlider.style.opacity = '1'; 306 | } 307 | } -------------------------------------------------------------------------------- /public/js/network-controls.js: -------------------------------------------------------------------------------- 1 | function showSearchResults(matches) { 2 | const resultsDiv = document.getElementById('searchResults'); 3 | 4 | if (matches.length === 0) { 5 | resultsDiv.style.display = 'none'; 6 | return; 7 | } 8 | 9 | resultsDiv.innerHTML = matches 10 | .map(node => ` 11 |
12 | ${node.name} (${node.count} appearances) 13 |
14 | `) 15 | .join(''); 16 | 17 | resultsDiv.style.display = 'block'; 18 | } 19 | 20 | function highlightNode(nodeId) { 21 | const node = currentNodes.find(n => n.id === nodeId); 22 | if (!node) return; 23 | 24 | const connectedIds = new Set(); 25 | currentLinks.forEach(link => { 26 | if (link.source.id === nodeId) connectedIds.add(link.target.id); 27 | if (link.target.id === nodeId) connectedIds.add(link.source.id); 28 | }); 29 | 30 | d3.selectAll('.node') 31 | .classed('highlighted-node', d => d.id === nodeId) 32 | .classed('dimmed', d => d.id !== nodeId && !connectedIds.has(d.id)); 33 | 34 | d3.selectAll('.link') 35 | .classed('dimmed', d => 36 | d.source.id !== nodeId && d.target.id !== nodeId 37 | ); 38 | 39 | document.getElementById('searchResults').style.display = 'none'; 40 | 41 | const svg = d3.select('#graph svg'); 42 | const transform = d3.zoomTransform(svg.node()); 43 | const dx = width / 2 - node.x * transform.k; 44 | const dy = height / 2 - node.y * transform.k; 45 | 46 | svg.transition() 47 | .duration(750) 48 | .call(zoom.transform, 49 | d3.zoomIdentity 50 | .translate(dx, dy) 51 | .scale(transform.k) 52 | ); 53 | } 54 | 55 | async function updateDirectory() { 56 | const directoryInput = document.getElementById('directoryInput'); 57 | const directory = directoryInput.value.trim(); 58 | const updateButton = document.getElementById('updateButton'); 59 | 60 | try { 61 | updateButton.disabled = true; 62 | const response = await fetch('/api/config/directory', { 63 | method: 'POST', 64 | headers: { 65 | 'Content-Type': 'application/json' 66 | }, 67 | body: JSON.stringify({ directory }) 68 | }); 69 | 70 | const result = await response.json(); 71 | 72 | if (result.success) { 73 | showDirectoryStatus('Directory updated successfully', 'success'); 74 | directoryInput.value = result.directory; 75 | } else { 76 | showDirectoryStatus(`Failed to update directory: ${result.error}`, 'error'); 77 | } 78 | } catch (error) { 79 | console.error('Error:', error); 80 | showDirectoryStatus(`Error updating directory: ${error.message}`, 'error'); 81 | } finally { 82 | updateButton.disabled = false; 83 | } 84 | } 85 | 86 | function showDirectoryStatus(message, type) { 87 | const containerDiv = document.querySelector('.directory-container'); 88 | 89 | const existingStatus = containerDiv.querySelector('.directory-status'); 90 | if (existingStatus) { 91 | existingStatus.remove(); 92 | } 93 | 94 | const statusDiv = document.createElement('div'); 95 | statusDiv.className = `directory-status ${type}`; 96 | statusDiv.textContent = message; 97 | 98 | containerDiv.appendChild(statusDiv); 99 | 100 | setTimeout(() => { 101 | statusDiv.remove(); 102 | }, 3000); 103 | } 104 | 105 | async function shareOnX() { 106 | try { 107 | const xShareButton = document.querySelector('button[aria-label="Share on X"]'); 108 | if (!xShareButton) { 109 | throw new Error('Share button not found'); 110 | } 111 | 112 | const originalContent = xShareButton.innerHTML; 113 | xShareButton.disabled = true; 114 | xShareButton.innerHTML = ``; 115 | 116 | const svgElement = document.querySelector('#graph svg'); 117 | if (!svgElement) { 118 | throw new Error('No visualization found'); 119 | } 120 | 121 | const { pngData } = await convertSVGToPNG(svgElement); 122 | console.log('Image converted successfully'); 123 | 124 | const response = await fetch('/api/upload/image', { 125 | method: 'POST', 126 | headers: { 127 | 'Content-Type': 'application/json' 128 | }, 129 | body: JSON.stringify({ imageData: pngData }) 130 | }); 131 | 132 | if (!response.ok) { 133 | const errorData = await response.text(); 134 | throw new Error(`Upload failed: ${errorData}`); 135 | } 136 | 137 | const result = await response.json(); 138 | if (!result.success || !result.url) { 139 | throw new Error('Invalid response from server'); 140 | } 141 | 142 | const text = "Check out my VRChat Friend Network visualization! #VRChatFriendShipVisualizer"; 143 | const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(result.url)}`; 144 | 145 | window.open( 146 | twitterUrl, 147 | 'Share on X', 148 | `width=550,height=420,left=${(window.innerWidth - 550) / 2},top=${(window.innerHeight - 420) / 2}` 149 | ); 150 | 151 | } catch (error) { 152 | console.error('Error sharing:', error); 153 | alert('Failed to share visualization: ' + error.message); 154 | } finally { 155 | const xShareButton = document.querySelector('button[aria-label="Share on X"]'); 156 | if (xShareButton) { 157 | xShareButton.disabled = false; 158 | xShareButton.innerHTML = `X`; 159 | } 160 | } 161 | } 162 | 163 | async function convertSVGToPNG(svgElement) { 164 | if (!svgElement) { 165 | throw new Error('No SVG element provided'); 166 | } 167 | 168 | const canvas = document.createElement('canvas'); 169 | const ctx = canvas.getContext('2d'); 170 | const svgData = new XMLSerializer().serializeToString(svgElement); 171 | const img = new Image(); 172 | const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); 173 | const url = URL.createObjectURL(svgBlob); 174 | 175 | try { 176 | await new Promise((resolve, reject) => { 177 | img.onload = function() { 178 | try { 179 | const width = svgElement.width.baseVal.value || img.width; 180 | const height = svgElement.height.baseVal.value || img.height; 181 | 182 | canvas.width = width; 183 | canvas.height = height; 184 | 185 | ctx.fillStyle = '#FFFFFF'; 186 | ctx.fillRect(0, 0, width, height); 187 | 188 | ctx.drawImage(img, 0, 0); 189 | resolve(); 190 | } catch (err) { 191 | reject(err); 192 | } 193 | }; 194 | img.onerror = () => reject(new Error('Failed to load SVG')); 195 | img.src = url; 196 | }); 197 | 198 | return { 199 | canvas, 200 | pngData: canvas.toDataURL('image/png'), 201 | svgData: svgData 202 | }; 203 | } finally { 204 | URL.revokeObjectURL(url); 205 | } 206 | } 207 | 208 | async function exportSVG() { 209 | try { 210 | const exportButton = document.querySelector('button[aria-label="Export SVG"]'); 211 | if (!exportButton) { 212 | throw new Error('Export button not found'); 213 | } 214 | 215 | const originalContent = exportButton.innerHTML; 216 | exportButton.disabled = true; 217 | exportButton.innerHTML = ``; 218 | 219 | const svgElement = document.querySelector('#graph svg'); 220 | if (!svgElement) { 221 | throw new Error('No visualization found'); 222 | } 223 | 224 | const svgData = new XMLSerializer().serializeToString(svgElement); 225 | const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); 226 | const url = URL.createObjectURL(svgBlob); 227 | 228 | const link = document.createElement('a'); 229 | link.href = url; 230 | link.download = `vrchat-network-${Date.now()}.svg`; 231 | document.body.appendChild(link); 232 | link.click(); 233 | document.body.removeChild(link); 234 | URL.revokeObjectURL(url); 235 | 236 | } catch (error) { 237 | console.error('Error exporting SVG:', error); 238 | alert('Failed to export SVG: ' + error.message); 239 | } finally { 240 | const exportButton = document.querySelector('button[aria-label="Export SVG"]'); 241 | if (exportButton) { 242 | exportButton.disabled = false; 243 | exportButton.innerHTML = `Export`; 244 | } 245 | } 246 | } 247 | 248 | function showSearchResults(matches) { 249 | const resultsDiv = document.getElementById('searchResults'); 250 | 251 | if (matches.length === 0) { 252 | resultsDiv.style.display = 'none'; 253 | return; 254 | } 255 | 256 | resultsDiv.innerHTML = matches 257 | .map(node => ` 258 |
259 | ${node.name} (${node.count} appearances) 260 |
261 | `) 262 | .join(''); 263 | 264 | resultsDiv.style.display = 'block'; 265 | } 266 | 267 | function clearSearch() { 268 | document.getElementById('searchInput').value = ''; 269 | document.getElementById('searchResults').style.display = 'none'; 270 | 271 | d3.selectAll('.node') 272 | .classed('highlighted-node', false) 273 | .classed('dimmed', false); 274 | 275 | d3.selectAll('.link') 276 | .classed('dimmed', false); 277 | } 278 | 279 | function highlightNode(nodeId) { 280 | const node = currentNodes.find(n => n.id === nodeId); 281 | if (!node) return; 282 | 283 | const connectedIds = new Set(); 284 | currentLinks.forEach(link => { 285 | if (link.source.id === nodeId) connectedIds.add(link.target.id); 286 | if (link.target.id === nodeId) connectedIds.add(link.source.id); 287 | }); 288 | 289 | d3.selectAll('.node') 290 | .classed('highlighted-node', d => d.id === nodeId) 291 | .classed('dimmed', d => d.id !== nodeId && !connectedIds.has(d.id)); 292 | 293 | d3.selectAll('.link') 294 | .classed('dimmed', d => 295 | d.source.id !== nodeId && d.target.id !== nodeId 296 | ); 297 | 298 | document.getElementById('searchResults').style.display = 'none'; 299 | 300 | const svg = d3.select('#graph svg'); 301 | const transform = d3.zoomTransform(svg.node()); 302 | const dx = width / 2 - node.x * transform.k; 303 | const dy = height / 2 - node.y * transform.k; 304 | 305 | svg.transition() 306 | .duration(750) 307 | .call(zoom.transform, 308 | d3.zoomIdentity 309 | .translate(dx, dy) 310 | .scale(transform.k) 311 | ); 312 | } -------------------------------------------------------------------------------- /public/js/network-generation.js: -------------------------------------------------------------------------------- 1 | async function generateMetadata() { 2 | const updateButton = document.getElementById('updateButton'); 3 | const stopButton = document.getElementById('stopButton'); 4 | const loadingOverlay = document.getElementById('loadingOverlay'); 5 | const progressStatus = document.getElementById('progressStatus'); 6 | const resultDiv = document.getElementById('result'); 7 | const debugDiv = document.getElementById('debug'); 8 | 9 | try { 10 | updateButton.disabled = true; 11 | stopButton.disabled = false; 12 | loadingOverlay.style.display = 'flex'; 13 | progressStatus.textContent = 'Initializing...'; 14 | debugDiv.innerHTML = ''; 15 | 16 | const startDateSlider = document.getElementById('startDateSlider'); 17 | const endDateSlider = document.getElementById('endDateSlider'); 18 | 19 | if (startDateSlider && endDateSlider) { 20 | startDateSlider.value = '0'; 21 | endDateSlider.value = endDateSlider.max || '100'; 22 | updateSliderTrack(); 23 | } 24 | 25 | currentEventSource = new EventSource('/api/metadata/generate'); 26 | 27 | currentEventSource.onmessage = async function(event) { 28 | const data = JSON.parse(event.data); 29 | 30 | switch(data.type) { 31 | case 'start': 32 | progressStatus.textContent = `Starting to process ${data.total} files...`; 33 | break; 34 | 35 | case 'progress': 36 | progressStatus.textContent = `Processing file ${data.current}/${data.total}`; 37 | if (data.error) { 38 | debugDiv.innerHTML += `
Error processing: ${data.file}
`; 39 | } 40 | break; 41 | 42 | case 'complete': 43 | closeEventSource(); 44 | if (data.stopped) { 45 | progressStatus.textContent = ''; 46 | resultDiv.innerHTML = ''; 47 | loadingOverlay.style.display = 'none'; 48 | 49 | d3.select('#graph svg').remove(); 50 | currentNodes = null; 51 | currentLinks = null; 52 | } else { 53 | progressStatus.textContent = 'Processing network data...'; 54 | await updateDateRange(); 55 | await visualizeNetworkData(); 56 | 57 | resultDiv.innerHTML = ` 58 |

Processing Results

59 |
Completed: ${data.successful} / ${data.total}
60 | ${data.failed > 0 ? `
Failed: ${data.failed}
` : ''} 61 | `; 62 | } 63 | updateButton.disabled = false; 64 | stopButton.disabled = true; 65 | break; 66 | 67 | case 'error': 68 | closeEventSource(); 69 | throw new Error(data.error); 70 | } 71 | }; 72 | 73 | currentEventSource.onerror = function() { 74 | closeEventSource(); 75 | updateButton.disabled = false; 76 | stopButton.disabled = true; 77 | throw new Error('EventSource failed'); 78 | }; 79 | 80 | } catch (error) { 81 | console.error('Error:', error); 82 | progressStatus.textContent = 'An error occurred'; 83 | resultDiv.innerHTML = `
Error: ${error.message}
`; 84 | updateButton.disabled = false; 85 | stopButton.disabled = true; 86 | } 87 | await updateDateRange(); 88 | } 89 | 90 | function closeEventSource() { 91 | if (currentEventSource) { 92 | currentEventSource.close(); 93 | currentEventSource = null; 94 | } 95 | } 96 | 97 | async function stopGeneration() { 98 | try { 99 | const response = await fetch('/api/metadata/stop', { 100 | method: 'POST' 101 | }); 102 | const result = await response.json(); 103 | 104 | if (!result.success) { 105 | throw new Error(result.error || 'Failed to stop generation'); 106 | } 107 | } catch (error) { 108 | console.error('Error stopping generation:', error); 109 | } 110 | } -------------------------------------------------------------------------------- /public/js/network-visualization.js: -------------------------------------------------------------------------------- 1 | async function visualizeNetworkData(providedMetadata = null) { 2 | try { 3 | d3.select('#graph svg').remove(); 4 | 5 | let allMetadata; 6 | 7 | if (providedMetadata) { 8 | allMetadata = providedMetadata; 9 | console.log(`Using provided metadata: ${allMetadata.length} files`); 10 | } else { 11 | console.log('Fetching metadata file list...'); 12 | const response = await fetch('/api/metadata/files'); 13 | const files = await response.json(); 14 | console.log(`${files.length} metadata files found`); 15 | 16 | if (!files || files.length === 0) { 17 | showPlaceholder('No Metadata Files', 'Please generate metadata first'); 18 | document.getElementById('applyDateFilter').disabled = true; 19 | return; 20 | } 21 | 22 | allMetadata = []; 23 | const concurrencyLimit = 5; 24 | let index = 0; 25 | 26 | async function fetchFile() { 27 | while (index < files.length) { 28 | const file = files[index++]; 29 | try { 30 | const res = await fetch(`/api/metadata/file/${file}`); 31 | if (!res.ok) { 32 | throw new Error(`Failed to fetch file: ${file}`); 33 | } 34 | const data = await res.json(); 35 | allMetadata.push(data); 36 | } catch (error) { 37 | console.error(`Error fetching file ${file}:`, error); 38 | } 39 | } 40 | } 41 | 42 | const workers = []; 43 | for (let i = 0; i < concurrencyLimit; i++) { 44 | workers.push(fetchFile()); 45 | } 46 | 47 | await Promise.all(workers); 48 | } 49 | 50 | if (allMetadata.length === 0) { 51 | showPlaceholder('No Valid Data Found', 'Please check your metadata files'); 52 | document.getElementById('progressStatus').textContent = 'No valid data found'; 53 | return; 54 | } 55 | 56 | console.log('Metadata loading completed'); 57 | 58 | const playerInfoMap = new Map(); 59 | const playerTimeMap = new Map(); 60 | const playerConnections = new Map(); 61 | const playerAppearances = new Map(); 62 | const relationshipStrength = new Map(); 63 | 64 | /** 65 | * Time-based weight calculation function 66 | * 67 | * Calculation criteria: 68 | * 1. monthsDiff: Calculates the time difference in months between current time and data timestamp 69 | * 2. Base decay rate: exp(-monthsDiff/36) 70 | * - Uses 36 months (3 years) as the base for gradual decay 71 | * - Maintains about 37% weight even after 3 years 72 | * 3. Minimum weight: 0.3 73 | * - Ensures even the oldest data maintains 30% influence 74 | */ 75 | 76 | const getTimeWeight = (timestamp) => { 77 | const now = new Date().getTime(); 78 | const monthsDiff = (now - timestamp) / (1000 * 60 * 60 * 24 * 30); 79 | const baseWeight = Math.exp(-monthsDiff / 36); 80 | return Math.max(0.3, baseWeight); 81 | }; 82 | 83 | allMetadata.forEach(metadata => { 84 | if (!metadata.players || !metadata.timestamp) return; 85 | 86 | const timestamp = new Date(metadata.timestamp).getTime(); 87 | metadata.players.forEach(player => { 88 | if (!player.id) return; 89 | 90 | if (!playerInfoMap.has(player.id)) { 91 | playerInfoMap.set(player.id, player.displayName); 92 | } 93 | 94 | if (!playerTimeMap.has(player.id)) { 95 | playerTimeMap.set(player.id, new Set()); 96 | } 97 | playerTimeMap.get(player.id).add(timestamp); 98 | }); 99 | }); 100 | 101 | allMetadata.forEach(metadata => { 102 | if (!metadata.players || !metadata.timestamp) return; 103 | const timestamp = new Date(metadata.timestamp).getTime(); 104 | const timeWeight = getTimeWeight(timestamp); 105 | 106 | metadata.players.forEach(player => { 107 | if (!player.id) return; 108 | const count = playerAppearances.get(player.id) || 0; 109 | playerAppearances.set(player.id, count + 1); 110 | }); 111 | 112 | const validPlayers = metadata.players.filter(player => player.id); 113 | validPlayers.forEach((player1, i) => { 114 | validPlayers.slice(i + 1).forEach(player2 => { 115 | if (player1.id === player2.id) return; 116 | 117 | const connectionKey = [player1.id, player2.id].sort().join('--'); 118 | const currentWeight = playerConnections.get(connectionKey) || 0; 119 | playerConnections.set(connectionKey, currentWeight + timeWeight); 120 | 121 | const set1 = playerTimeMap.get(player1.id); 122 | const set2 = playerTimeMap.get(player2.id); 123 | const intersection = new Set([...set1].filter(x => set2.has(x))); 124 | const union = new Set([...set1, ...set2]); 125 | const jaccardCoeff = intersection.size / union.size; 126 | 127 | relationshipStrength.set(connectionKey, jaccardCoeff); 128 | }); 129 | }); 130 | }); 131 | 132 | const avgAppearances = Array.from(playerAppearances.values()) 133 | .reduce((a, b) => a + b, 0) / playerAppearances.size; 134 | const stdAppearances = Math.sqrt( 135 | Array.from(playerAppearances.values()) 136 | .reduce((a, b) => a + Math.pow(b - avgAppearances, 2), 0) / 137 | playerAppearances.size 138 | ); 139 | 140 | const nodes = Array.from(playerAppearances.entries()) 141 | .filter(([_, count]) => count >= Math.max(2, avgAppearances - 2 * stdAppearances)) 142 | .map(([id, count]) => ({ 143 | id: id, 144 | name: playerInfoMap.get(id) || id, 145 | // masking option for top image (temp) 146 | // TODO : あとで機能として追加したい 147 | // name: (playerInfoMap.get(id) || id).replace(/^(.)(.+)$/, '$1' + '*'.repeat(2)), 148 | count: count 149 | })); 150 | 151 | const validNodeIds = new Set(nodes.map(n => n.id)); 152 | 153 | const links = Array.from(playerConnections.entries()) 154 | .filter(([key, weight]) => { 155 | const [source, target] = key.split('--'); 156 | if (!validNodeIds.has(source) || !validNodeIds.has(target)) return false; 157 | 158 | const jaccardCoeff = relationshipStrength.get(key); 159 | return jaccardCoeff >= 0.1 && weight >= 1; 160 | }) 161 | .map(([key, weight]) => { 162 | const [source, target] = key.split('--'); 163 | return { 164 | source: source, 165 | target: target, 166 | value: weight, 167 | strength: relationshipStrength.get(key) 168 | }; 169 | }); 170 | 171 | function filterCircularNodes(nodes, links) { 172 | const maxAppearanceNode = nodes.reduce((max, node) => { 173 | return node.count > max.count ? node : max; 174 | }, nodes[0]); 175 | 176 | const nodeStats = new Map(); 177 | 178 | nodes.forEach(node => { 179 | const connectedLinks = links.filter(link => link.source === node.id || link.target === node.id); 180 | const hasMaxNodeConnection = connectedLinks.some(link => 181 | link.source === maxAppearanceNode.id || link.target === maxAppearanceNode.id 182 | ); 183 | 184 | const strengths = connectedLinks.map(link => link.strength); 185 | const topStrengths = strengths.sort((a, b) => b - a).slice(0, 10); 186 | const mean = topStrengths.reduce((sum, s) => sum + s, 0) / topStrengths.length; 187 | const stdDev = Math.sqrt( 188 | topStrengths.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / topStrengths.length 189 | ); 190 | const cv = mean === 0 ? 0 : stdDev / mean; 191 | 192 | nodeStats.set(node.id, { topStrengths, hasMaxNodeConnection, mean, stdDev, cv }); 193 | }); 194 | 195 | const CV_THRESHOLD = 0.05; 196 | 197 | return nodes.filter(node => { 198 | const stats = nodeStats.get(node.id); 199 | if (!stats || stats.topStrengths.length < 3) return true; 200 | if (stats.hasMaxNodeConnection) return true; 201 | return stats.cv >= CV_THRESHOLD; 202 | }); 203 | } 204 | 205 | const filteredNodes = filterCircularNodes(nodes, links); 206 | console.log(`Filtered out ${nodes.length - filteredNodes.length} circular reference nodes`); 207 | 208 | currentNodes = filteredNodes; 209 | currentLinks = links.filter(l => 210 | filteredNodes.some(n => n.id === l.source) && 211 | filteredNodes.some(n => n.id === l.target) 212 | ); 213 | 214 | if (filteredNodes.length === 0) { 215 | showPlaceholder('No Significant Connections', 'Try adjusting the time period or connection threshold'); 216 | document.getElementById('progressStatus').textContent = 'No significant connections found'; 217 | document.getElementById('applyDateFilter').disabled = false; 218 | return; 219 | } 220 | 221 | width = document.getElementById('graph').clientWidth; 222 | height = document.getElementById('graph').clientHeight; 223 | 224 | const svg = d3.select('#graph') 225 | .append('svg') 226 | .attr('width', width) 227 | .attr('height', height); 228 | 229 | const g = svg.append('g'); 230 | 231 | const colorScale = d3.scaleSequential() 232 | .domain([0, d3.max(filteredNodes, d => d.count)]) 233 | .interpolator(d3.interpolateYlOrRd); 234 | 235 | const simulation = d3.forceSimulation(filteredNodes) 236 | .force('link', d3.forceLink(currentLinks) 237 | .id(d => d.id) 238 | .distance(d => 200 / (d.strength || 1)) 239 | .strength(d => 0.1 + d.strength * 0.9)) 240 | .force('charge', d3.forceManyBody() 241 | .strength(d => -500 * Math.sqrt(d.count / avgAppearances)) 242 | .distanceMax(1000)) 243 | .force('collide', d3.forceCollide() 244 | .radius(d => Math.sqrt(d.count) * 10 + 20) 245 | .strength(0.5)) 246 | .force('x', d3.forceX(width / 2).strength(0.03)) 247 | .force('y', d3.forceY(height / 2).strength(0.03)) 248 | .velocityDecay(0.3); 249 | 250 | const linkElements = g.append('g') 251 | .selectAll('line') 252 | .data(currentLinks) 253 | .join('line') 254 | .attr('class', 'link') 255 | .style('stroke', d => d.strength <= 0.2 ? '#ddd' : '#999') 256 | .style('stroke-width', d => Math.max(Math.sqrt(d.value), 0.5)) 257 | .style('stroke-opacity', d => { 258 | if (d.strength <= 0.2) return 0.1; 259 | if (d.strength <= 0.5) return 0.2; 260 | return 0.4; 261 | }); 262 | 263 | const drag = d3.drag() 264 | .on('start', (event, d) => { 265 | if (!event.active) simulation.alphaTarget(0.3).restart(); 266 | d.fx = d.x; 267 | d.fy = d.y; 268 | }) 269 | .on('drag', (event, d) => { 270 | d.fx = event.x; 271 | d.fy = event.y; 272 | }) 273 | .on('end', (event, d) => { 274 | if (!event.active) simulation.alphaTarget(0); 275 | d.fx = null; 276 | d.fy = null; 277 | }); 278 | 279 | const nodeElements = g.append('g') 280 | .selectAll('g') 281 | .data(filteredNodes) 282 | .join('g') 283 | .attr('class', 'node') 284 | .call(drag); 285 | 286 | nodeElements.append('circle') 287 | .attr('r', d => Math.sqrt(d.count) * 8 + 5) 288 | .style('fill', d => colorScale(d.count)) 289 | .style('stroke', '#fff') 290 | .style('stroke-width', 2) 291 | .style('stroke-opacity', 0.8) 292 | .style('fill-opacity', 0.7); 293 | 294 | nodeElements.append('text') 295 | .text(d => d.name) 296 | .attr('x', d => Math.sqrt(d.count) * 8 + 8) 297 | .attr('y', 5) 298 | .style('font-size', d => Math.min(12 + d.count / 2, 16) + 'px') 299 | .style('fill', '#333') 300 | .style('font-weight', d => d.count > avgAppearances ? 'bold' : 'normal'); 301 | 302 | const tooltip = d3.select('body').append('div') 303 | .attr('class', 'tooltip') 304 | .style('opacity', 0); 305 | 306 | nodeElements.on('mouseover', function(event, d) { 307 | const connectedIds = new Set(); 308 | const connectionInfo = new Map(); 309 | 310 | currentLinks.forEach(l => { 311 | if (l.source.id === d.id) { 312 | connectedIds.add(l.target.id); 313 | connectionInfo.set(l.target.id, { 314 | strength: l.strength, 315 | value: l.value 316 | }); 317 | } 318 | if (l.target.id === d.id) { 319 | connectedIds.add(l.source.id); 320 | connectionInfo.set(l.source.id, { 321 | strength: l.strength, 322 | value: l.value 323 | }); 324 | } 325 | }); 326 | 327 | linkElements.style('stroke-opacity', l => 328 | (l.source.id === d.id || l.target.id === d.id) 329 | ? Math.min(0.9, l.strength + 0.3) 330 | : (l.strength <= 0.2 ? 0.1 : 0.2) 331 | ); 332 | 333 | nodeElements.style('opacity', n => 334 | connectedIds.has(n.id) || n.id === d.id ? 1 : 0.3 335 | ); 336 | 337 | tooltip.transition() 338 | .duration(200) 339 | .style('opacity', .9); 340 | 341 | const connections = Array.from(connectedIds) 342 | .map(id => { 343 | const info = connectionInfo.get(id); 344 | const node = filteredNodes.find(n => n.id === id); 345 | return { 346 | name: node.name, 347 | strength: info.strength, 348 | value: info.value 349 | }; 350 | }) 351 | .sort((a, b) => b.strength - a.strength) 352 | .map(c => `${c.name} (Relationship Strength: ${(c.strength * 100).toFixed(1)}%, ${c.value.toFixed(1)} times)`) 353 | .join('
'); 354 | 355 | tooltip.html(` 356 | ${d.name}
357 | Total Appearances: ${d.count}
358 |
359 | Connected Players:
360 | ${connections} 361 | `) 362 | .style('left', (event.pageX + 10) + 'px') 363 | .style('top', (event.pageY - 28) + 'px'); 364 | }) 365 | .on('mouseout', function() { 366 | linkElements.style('stroke-opacity', d => { 367 | if (d.strength <= 0.2) return 0.1; 368 | if (d.strength <= 0.5) return 0.2; 369 | return 0.4; 370 | }); 371 | nodeElements.style('opacity', 1); 372 | 373 | tooltip.transition() 374 | .duration(500) 375 | .style('opacity', 0); 376 | }); 377 | 378 | zoom = d3.zoom() 379 | .scaleExtent([0.1, 4]) 380 | .on('zoom', (event) => { 381 | g.attr('transform', event.transform); 382 | }); 383 | 384 | svg.call(zoom) 385 | .call(zoom.transform, d3.zoomIdentity); 386 | 387 | simulation.on('tick', () => { 388 | linkElements 389 | .attr('x1', d => d.source.x) 390 | .attr('y1', d => d.source.y) 391 | .attr('x2', d => d.target.x) 392 | .attr('y2', d => d.target.y); 393 | 394 | nodeElements.attr('transform', d => `translate(${d.x},${d.y})`); 395 | }); 396 | 397 | document.getElementById('applyDateFilter').disabled = false; 398 | 399 | try { 400 | const response = await fetch('/api/metadata/date-range'); 401 | if (!response.ok) throw new Error('Failed to fetch date range'); 402 | 403 | const { start, end } = await response.json(); 404 | if (start && end) { 405 | document.getElementById('startDate').textContent = start; 406 | document.getElementById('endDate').textContent = end; 407 | document.getElementById('startDateSlider').disabled = false; 408 | document.getElementById('endDateSlider').disabled = false; 409 | 410 | } else { 411 | document.getElementById('startDate').textContent = 'No Data'; 412 | document.getElementById('endDate').textContent = 'No Data'; 413 | document.getElementById('startDateSlider').disabled = true; 414 | document.getElementById('endDateSlider').disabled = true; 415 | } 416 | } catch (error) { 417 | console.error('Error fetching date range:', error); 418 | document.getElementById('startDate').textContent = 'Error'; 419 | document.getElementById('endDate').textContent = 'Error'; 420 | document.getElementById('startDateSlider').disabled = true; 421 | document.getElementById('endDateSlider').disabled = true; 422 | } 423 | 424 | } catch (error) { 425 | console.error('Error visualizing network:', error); 426 | showPlaceholder('Error Occurred', 'Please check the debug information below'); 427 | document.getElementById('progressStatus').textContent = 'Error during network visualization'; 428 | 429 | const debugDiv = document.getElementById('debug'); 430 | if (debugDiv) { 431 | debugDiv.innerHTML = ` 432 |
433 | Error Details:
434 | ${error.message}
435 | ${error.stack} 436 |
437 | `; 438 | } 439 | 440 | throw error; 441 | } finally { 442 | const loadingOverlay = document.getElementById('loadingOverlay'); 443 | if (loadingOverlay) { 444 | loadingOverlay.style.display = 'none'; 445 | } 446 | } 447 | } 448 | 449 | function showPlaceholder(mainMessage, subMessage) { 450 | document.getElementById('applyDateFilter').disabled = true; 451 | 452 | const width = document.getElementById('graph').clientWidth; 453 | const height = document.getElementById('graph').clientHeight; 454 | 455 | const svg = d3.select('#graph') 456 | .append('svg') 457 | .attr('width', width) 458 | .attr('height', height); 459 | 460 | const background = svg.append('g') 461 | .attr('class', 'no-data-placeholder'); 462 | 463 | background.append('rect') 464 | .attr('width', width) 465 | .attr('height', height); 466 | 467 | const textGroup = background.append('g') 468 | .attr('transform', `translate(${width/2}, ${height/2})`); 469 | 470 | textGroup.append('text') 471 | .attr('class', 'main-message') 472 | .attr('text-anchor', 'middle') 473 | .attr('dominant-baseline', 'middle') 474 | .attr('dy', '-1em') 475 | .text(mainMessage); 476 | 477 | textGroup.append('text') 478 | .attr('class', 'sub-message') 479 | .attr('text-anchor', 'middle') 480 | .attr('dominant-baseline', 'middle') 481 | .attr('dy', '1em') 482 | .text(subMessage); 483 | } -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import express, { Request, Response } from "express"; 3 | import { ImageController } from "./controllers/imageController"; 4 | import { MetadataController } from "./controllers/metadataController"; 5 | import { createRouter } from "./routes/apiRoutes"; 6 | import { FileStorageService } from "./services/fileStorageService"; 7 | import { ImageService } from "./services/imageService"; 8 | import { MetadataService } from "./services/metadataService"; 9 | import type { Config } from "./types"; 10 | import { errorHandler } from "./utils/errorHandler"; 11 | 12 | const metadataDir: string = path.join( 13 | process.env["USERPROFILE"] || "", 14 | "Pictures", 15 | "VRChat", 16 | "metadata" 17 | ); 18 | 19 | // 設定 20 | const portEnv = process.env["ELECTRON_RUN_AS_NODE"] ? "0" : (process.env["PORT"] || "3000"); 21 | const PORT: number = Number.parseInt(portEnv, 10); 22 | 23 | const config: Config = { 24 | imgDir: path.join(process.env["USERPROFILE"] || "", "Pictures", "VRChat"), 25 | metadataDir, 26 | uploadDir: path.join(__dirname, "../public/uploads") 27 | }; 28 | 29 | const app = express(); 30 | 31 | // ServiceとControllerの初期化 32 | const metadataService = new MetadataService(config); 33 | const metadataController = new MetadataController(metadataService); 34 | const imageService = new ImageService(); 35 | const imageController = new ImageController(imageService); 36 | const fileStorageService = new FileStorageService(config.metadataDir); 37 | 38 | app.use(express.json({ limit: "50mb" })); 39 | app.use(express.static(path.join(__dirname, "../public"))); 40 | app.use("/icon", express.static(path.join(__dirname, "../icon"))); 41 | 42 | // Router設定 43 | const server = app.listen(PORT, () => { 44 | const address = server.address(); 45 | if (address && typeof address === "object") { 46 | console.log(`Server is running on port ${address.port}`); 47 | } else { 48 | console.log(`Server is running on port ${PORT}`); 49 | } 50 | }); 51 | 52 | const apiRouter = createRouter(metadataController, imageController, fileStorageService, server); // Pass server instance 53 | app.use("/api", apiRouter); 54 | 55 | app.get("/", (_req: Request, res: Response) => { 56 | res.sendFile(path.join(__dirname, "../public/index.html")); 57 | }); 58 | 59 | app.use(errorHandler); 60 | 61 | export { app, server }; 62 | -------------------------------------------------------------------------------- /src/controllers/imageController.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { ImageService } from "../services/imageService"; 3 | 4 | export class ImageController { 5 | constructor(private imageService: ImageService) {} 6 | 7 | async uploadImage(req: Request, res: Response): Promise { 8 | try { 9 | const { imageData } = req.body as { imageData?: string }; 10 | if (!imageData) { 11 | throw new Error("No image data provided"); 12 | } 13 | const imageUrl = await this.imageService.saveImage(imageData); 14 | res.json({ success: true, url: imageUrl }); 15 | } catch (error) { 16 | console.error("Error uploading image:", error); 17 | res.status(500).json({ 18 | success: false, 19 | error: error instanceof Error ? error.message : "Unknown error", 20 | }); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/controllers/metadataController.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataService } from "../services/metadataService"; 2 | import type { Config, ProgressCallback } from "../types"; 3 | 4 | export class MetadataController { 5 | private isGenerating = false; 6 | 7 | constructor(private metadataService: MetadataService) {} 8 | 9 | updateConfig(config: Partial): void { 10 | this.metadataService.updateConfig(config); 11 | } 12 | 13 | async generateMetadata(progressCallback: ProgressCallback): Promise<{ 14 | total?: number; 15 | successful?: number; 16 | failed?: number; 17 | details?: any[]; 18 | stopped?: boolean; 19 | }> { 20 | try { 21 | this.isGenerating = true; 22 | await this.metadataService.initialize(); 23 | const results = await this.metadataService.processDirectory(progressCallback); 24 | return { 25 | total: results.length, 26 | successful: results.filter((r) => r.success).length, 27 | failed: results.filter((r) => !r.success).length, 28 | details: results, 29 | }; 30 | } catch (error) { 31 | if (error instanceof Error && error.message === "Generation stopped") { 32 | await this.metadataService.clearMetadataDirectory(); 33 | return { stopped: true }; 34 | } 35 | console.error("Error generating metadata:", error); 36 | throw error; 37 | } finally { 38 | this.isGenerating = false; 39 | } 40 | } 41 | 42 | async stopGeneration(): Promise { 43 | if (this.isGenerating) { 44 | this.metadataService.stopProcessing = true; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/endpoints/configDirectoryEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import type { Request, Response } from "express"; 4 | import type { MetadataController } from "../controllers/metadataController"; 5 | 6 | export class ConfigDirectoryEndpoint { 7 | constructor(private metadataController: MetadataController) {} 8 | 9 | async handle(req: Request, res: Response): Promise { 10 | try { 11 | const { directory } = req.body as { directory?: string }; 12 | const absolutePath = directory 13 | ? path.resolve(directory) 14 | : path.join(process.env["USERPROFILE"] || "", "Pictures", "VRChat"); 15 | 16 | const stats = await fs.stat(absolutePath); 17 | if (!stats.isDirectory()) { 18 | res.status(400).json({ success: false, error: "Not a directory" }); 19 | return; 20 | } 21 | 22 | await fs.access(absolutePath, fs.constants.R_OK); 23 | this.metadataController.updateConfig({ imgDir: absolutePath }); 24 | res.json({ success: true, directory: absolutePath }); 25 | } catch (error) { 26 | console.error("Error in ConfigDirectoryEndpoint:", error); 27 | res.status(400).json({ 28 | success: false, 29 | error: error instanceof Error ? error.message : "Unknown error", 30 | }); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/endpoints/imageUploadEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { ImageController } from "../controllers/imageController"; 3 | 4 | export class ImageUploadEndpoint { 5 | constructor(private imageController: ImageController) {} 6 | 7 | async handle(req: Request, res: Response): Promise { 8 | try { 9 | await this.imageController.uploadImage(req, res); 10 | } catch (error) { 11 | throw error; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/endpoints/metadataDateRangeEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { FileStorageService } from "../services/fileStorageService"; 3 | 4 | export class MetadataDateRangeEndpoint { 5 | constructor(private fileStorageService: FileStorageService) {} 6 | 7 | async handle(_req: Request, res: Response): Promise { 8 | try { 9 | const dateRange = await this.fileStorageService.getMetadataDateRange(); 10 | res.json(dateRange); 11 | } catch (error) { 12 | console.error("Error in MetadataDateRangeEndpoint:", error); 13 | res.status(500).json({ 14 | error: error instanceof Error ? error.message : "Unknown error", 15 | }); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/endpoints/metadataFileEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { FileStorageService } from "../services/fileStorageService"; 3 | 4 | export class MetadataFileEndpoint { 5 | constructor(private fileStorageService: FileStorageService) {} 6 | 7 | async handle(req: Request, res: Response): Promise { 8 | try { 9 | const filename: string = req.params["filename"]!; 10 | const data = await this.fileStorageService.readMetadataFile(filename); 11 | res.json(data); 12 | } catch (error) { 13 | console.error("Error in MetadataFileEndpoint:", error); 14 | res.status(500).json({ 15 | error: error instanceof Error ? error.message : "Unknown error", 16 | }); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/endpoints/metadataFilesEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { FileStorageService } from "../services/fileStorageService"; 3 | 4 | export class MetadataFilesEndpoint { 5 | constructor(private fileStorageService: FileStorageService) {} 6 | 7 | async handle(_req: Request, res: Response): Promise { 8 | try { 9 | const files = await this.fileStorageService.getAllMetadataFiles(); 10 | res.json(files); 11 | } catch (error) { 12 | console.error("Error in MetadataFilesEndpoint:", error); 13 | res.status(500).json({ 14 | error: error instanceof Error ? error.message : "Unknown error", 15 | }); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/endpoints/metadataFilterEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { FileStorageService } from "../services/fileStorageService"; 3 | 4 | export class MetadataFilterEndpoint { 5 | constructor(private fileStorageService: FileStorageService) {} 6 | 7 | async handle(req: Request, res: Response): Promise { 8 | try { 9 | const { startDate, endDate } = req.body as { startDate: string; endDate: string }; 10 | const filteredFiles = await this.fileStorageService.filterMetadataFilesByDate(startDate, endDate); 11 | res.json(filteredFiles); 12 | } catch (error) { 13 | console.error("Error in MetadataFilterEndpoint:", error); 14 | res.status(500).json({ 15 | error: error instanceof Error ? error.message : "Unknown error", 16 | }); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/endpoints/metadataGenerationEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import type { MetadataController } from "../controllers/metadataController"; 3 | import type { ProgressCallback } from "../types"; 4 | 5 | export class MetadataGenerationEndpoint { 6 | constructor(private metadataController: MetadataController) {} 7 | 8 | async handle(res: Response): Promise { 9 | res.setHeader("Content-Type", "text/event-stream"); 10 | res.setHeader("Cache-Control", "no-cache"); 11 | res.setHeader("Connection", "keep-alive"); 12 | 13 | try { 14 | const sendProgress: ProgressCallback = (progress) => { 15 | res.write(`data: ${JSON.stringify(progress)}\n\n`); 16 | }; 17 | 18 | const result = await this.metadataController.generateMetadata(sendProgress); 19 | sendProgress({ 20 | type: "complete", 21 | total: result.total, 22 | successful: result.successful, 23 | failed: result.failed, 24 | details: result.details, 25 | stopped: result.stopped, 26 | }); 27 | res.end(); 28 | } catch (error) { 29 | console.error("Error in MetadataGenerationEndpoint:", error); 30 | res.write( 31 | `data: ${JSON.stringify({ 32 | type: "error", 33 | error: error instanceof Error ? error.message : "Unknown error", 34 | })}\n\n` 35 | ); 36 | res.end(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/endpoints/metadataStopEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import type { MetadataController } from "../controllers/metadataController"; 3 | 4 | export class MetadataStopEndpoint { 5 | constructor(private metadataController: MetadataController) {} 6 | 7 | async handle(_req: Request, res: Response): Promise { 8 | try { 9 | await this.metadataController.stopGeneration(); 10 | res.json({ success: true }); 11 | } catch (error) { 12 | throw error; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/endpoints/serverShutdownEndpoint.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | 3 | export class ServerShutdownEndpoint { 4 | constructor(private server: any) {} 5 | 6 | async handle(_req: Request, res: Response): Promise { 7 | try { 8 | res.json({ success: true, message: "Server shutdown initiated" }); 9 | setTimeout(() => { 10 | this.server.close(() => { 11 | process.exit(0); 12 | }); 13 | }, 1000); 14 | } catch (error) { 15 | console.error("Error in ServerShutdownEndpoint:", error); 16 | res.status(500).json({ 17 | success: false, 18 | error: error instanceof Error ? error.message : "Unknown error", 19 | }); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/apiRoutes.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import { NextFunction, Request, Response, Router } from "express"; 4 | import type { ImageController } from "../controllers/imageController"; 5 | import type { MetadataController } from "../controllers/metadataController"; 6 | import { ConfigDirectoryEndpoint } from "../endpoints/configDirectoryEndpoint"; 7 | import { ImageUploadEndpoint } from "../endpoints/imageUploadEndpoint"; 8 | import { MetadataDateRangeEndpoint } from "../endpoints/metadataDateRangeEndpoint"; 9 | import { MetadataFileEndpoint } from "../endpoints/metadataFileEndpoint"; 10 | import { MetadataFilesEndpoint } from "../endpoints/metadataFilesEndpoint"; 11 | import { MetadataFilterEndpoint } from "../endpoints/metadataFilterEndpoint"; 12 | import { MetadataGenerationEndpoint } from "../endpoints/metadataGenerationEndpoint"; 13 | import { MetadataStopEndpoint } from "../endpoints/metadataStopEndpoint"; 14 | import { ServerShutdownEndpoint } from "../endpoints/serverShutdownEndpoint"; 15 | import type { FileStorageService } from "../services/fileStorageService"; 16 | 17 | export function createRouter( 18 | metadataController: MetadataController, 19 | imageController: ImageController, 20 | fileStorageService: FileStorageService, 21 | server: any 22 | ): Router { 23 | const router: Router = Router(); 24 | 25 | const imageUploadEndpoint = new ImageUploadEndpoint(imageController); 26 | const metadataGenerationEndpoint = new MetadataGenerationEndpoint(metadataController); 27 | const metadataStopEndpoint = new MetadataStopEndpoint(metadataController); 28 | const configDirectoryEndpoint = new ConfigDirectoryEndpoint(metadataController); 29 | const metadataFilesEndpoint = new MetadataFilesEndpoint(fileStorageService); 30 | const metadataFileEndpoint = new MetadataFileEndpoint(fileStorageService); 31 | const metadataDateRangeEndpoint = new MetadataDateRangeEndpoint(fileStorageService); 32 | const metadataFilterEndpoint = new MetadataFilterEndpoint(fileStorageService); 33 | const serverShutdownEndpoint = new ServerShutdownEndpoint(server); 34 | 35 | /** 36 | * POST /config/directory 37 | * Sets the working directory for image processing 38 | */ 39 | router.post("/config/directory", (_req: Request, res: Response, next: NextFunction) => 40 | configDirectoryEndpoint.handle(_req, res).catch(next) 41 | ); 42 | 43 | /** 44 | * GET /metadata/date-range 45 | * Retrieves the date range of available metadata files 46 | */ 47 | router.get("/metadata/date-range", (_req: Request, res: Response, next: NextFunction) => 48 | metadataDateRangeEndpoint.handle(_req, res).catch(next) 49 | ); 50 | 51 | /** 52 | * GET /metadata/generate 53 | * Initiates metadata generation process with SSE progress updates 54 | */ 55 | router.get("/metadata/generate", (_req: Request, res: Response, next: NextFunction) => 56 | metadataGenerationEndpoint.handle(res).catch(next) 57 | ); 58 | 59 | /** 60 | * GET /metadata/files 61 | * Lists all available metadata files 62 | */ 63 | router.get("/metadata/files", (_req: Request, res: Response, next: NextFunction) => 64 | metadataFilesEndpoint.handle(_req, res).catch(next) 65 | ); 66 | 67 | /** 68 | * GET /metadata/file/:filename(*) 69 | * Retrieves content of a specific metadata file 70 | */ 71 | router.get("/metadata/file/:filename(*)", (_req: Request, res: Response, next: NextFunction) => 72 | metadataFileEndpoint.handle(_req, res).catch(next) 73 | ); 74 | 75 | /** 76 | * POST /metadata/filter 77 | * Filters metadata files by date range 78 | */ 79 | router.post("/metadata/filter", (_req: Request, res: Response, next: NextFunction) => 80 | metadataFilterEndpoint.handle(_req, res).catch(next) 81 | ); 82 | 83 | /** 84 | * POST /metadata/stop 85 | * Stops ongoing metadata generation process 86 | */ 87 | router.post("/metadata/stop", (_req: Request, res: Response, next: NextFunction) => 88 | metadataStopEndpoint.handle(_req, res).catch(next) 89 | ); 90 | 91 | /** 92 | * POST /upload/image 93 | * Handles image file upload and processing 94 | */ 95 | router.post("/upload/image", (_req: Request, res: Response, next: NextFunction) => 96 | imageUploadEndpoint.handle(_req, res).catch(next) 97 | ); 98 | 99 | /** 100 | * POST /server/shutdown 101 | * Safely shuts down the server 102 | */ 103 | router.post("/server/shutdown", (_req: Request, res: Response, next: NextFunction) => 104 | serverShutdownEndpoint.handle(_req, res).catch(next) 105 | ); 106 | 107 | /** 108 | * GET /version 109 | * Returns the application version from VERSION file 110 | */ 111 | router.get("/version", async (_req: Request, res: Response) => { 112 | try { 113 | const versionPath = path.join(__dirname, "../../VERSION"); 114 | const version = await fs.readFile(versionPath, "utf-8"); 115 | res.json({ version: version.trim() }); 116 | } catch (error) { 117 | console.error("Error reading version:", error); 118 | res.json({ version: "0.0.0" }); 119 | } 120 | }); 121 | 122 | return router; 123 | } -------------------------------------------------------------------------------- /src/services/fileStorageService.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | 4 | export class FileStorageService { 5 | private metadataDir: string; 6 | 7 | constructor(metadataDir: string) { 8 | this.metadataDir = metadataDir; 9 | } 10 | 11 | async getAllMetadataFiles(): Promise { 12 | const getAllFiles = async (dir: string): Promise => { 13 | const files = await fs.readdir(dir, { withFileTypes: true }); 14 | const paths = await Promise.all( 15 | files.map(async (file) => { 16 | const filePath = path.join(dir, file.name); 17 | if (file.isDirectory()) { 18 | return getAllFiles(filePath); 19 | } else if (file.name.endsWith(".json")) { 20 | return path.relative(this.metadataDir, filePath); 21 | } 22 | return null; 23 | }) 24 | ); 25 | return paths.flat().filter((p): p is string => p !== null); 26 | }; 27 | 28 | return getAllFiles(this.metadataDir); 29 | } 30 | 31 | async readMetadataFile(filename: string): Promise { 32 | const filePath = path.join(this.metadataDir, filename); 33 | const data = await fs.readFile(filePath, "utf-8"); 34 | return JSON.parse(data); 35 | } 36 | 37 | async getMetadataDateRange(): Promise<{ 38 | exists: boolean; 39 | hasFiles?: boolean; 40 | hasValidDates?: boolean; 41 | start: string | null; 42 | end: string | null; 43 | }> { 44 | try { 45 | const metadataExists = await fs 46 | .access(this.metadataDir) 47 | .then(() => true) 48 | .catch(() => false); 49 | 50 | if (!metadataExists) { 51 | return { exists: false, start: null, end: null }; 52 | } 53 | 54 | const files = await this.getAllMetadataFiles(); 55 | if (files.length === 0) { 56 | return { exists: true, hasFiles: false, start: null, end: null }; 57 | } 58 | 59 | const dates = files 60 | .map((filename) => { 61 | const patterns = [/VRChat_(\d{4}-\d{2})-\d{2}/, /VRChat_\d+x\d+_(\d{4}-\d{2})-\d{2}/]; 62 | for (const pattern of patterns) { 63 | const match = filename.match(pattern); 64 | if (match) { 65 | return match[1]; 66 | } 67 | } 68 | return null; 69 | }) 70 | .filter((date): date is string => date !== null); 71 | 72 | if (dates.length === 0) { 73 | return { exists: true, hasFiles: true, hasValidDates: false, start: null, end: null }; 74 | } 75 | 76 | dates.sort(); 77 | return { 78 | exists: true, 79 | hasFiles: true, 80 | hasValidDates: true, 81 | start: dates[0] ?? null, 82 | end: dates[dates.length - 1] ?? null, 83 | }; 84 | } catch (error) { 85 | console.error("Error getting metadata date range:", error); 86 | return { exists: false, start: null, end: null }; 87 | } 88 | } 89 | 90 | async filterMetadataFilesByDate(startDate: string, endDate: string): Promise { 91 | const files = await this.getAllMetadataFiles(); 92 | return files.filter((filename) => { 93 | const patterns = [/VRChat_(\d{4}-\d{2})-\d{2}/, /VRChat_\d+x\d+_(\d{4}-\d{2})-\d{2}/]; 94 | for (const pattern of patterns) { 95 | const match = filename.match(pattern); 96 | if (match && match[1]) { 97 | const fileDate = match[1]; 98 | return fileDate >= startDate && fileDate <= endDate; 99 | } 100 | } 101 | return false; 102 | }); 103 | } 104 | } -------------------------------------------------------------------------------- /src/services/imageService.ts: -------------------------------------------------------------------------------- 1 | import FormData from "form-data"; 2 | import fetch from "node-fetch"; 3 | 4 | export class ImageService { 5 | private UPLOAD_URL = "https://tmpfiles.org/api/v1/upload"; 6 | 7 | async saveImage(base64Data: string): Promise { 8 | try { 9 | console.log("Receiving image data..."); 10 | const matches = base64Data.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); 11 | if (!matches || matches.length !== 3) { 12 | throw new Error("Invalid base64 data"); 13 | } 14 | const base64String = matches[2]!; 15 | const buffer = Buffer.from(base64String, "base64"); 16 | 17 | const formData = new FormData(); 18 | formData.append("file", buffer, { 19 | filename: "network.png", 20 | contentType: "image/png", 21 | }); 22 | 23 | console.log("Uploading to tmpfiles.org..."); 24 | const response = await fetch(this.UPLOAD_URL, { 25 | method: "POST", 26 | body: formData, 27 | }); 28 | 29 | console.log("Response status:", response.status); 30 | const result = await response.json(); 31 | console.log("Response:", result); 32 | 33 | if (!response.ok) { 34 | throw new Error(`Upload failed: ${response.statusText} (${response.status})`); 35 | } 36 | 37 | if (!result.data || !result.data.url) { 38 | throw new Error("Invalid response from server"); 39 | } 40 | 41 | const viewUrl = result.data.url.replace("tmpfiles.org/", "tmpfiles.org/dl/"); 42 | console.log("Upload successful:", viewUrl); 43 | return viewUrl; 44 | } catch (error) { 45 | console.error("Error uploading image:", error); 46 | throw error; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/services/metadataService.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import type { Config, MetadataResult, ProgressCallback } from "../types"; 4 | import { PNGParser } from "../utils/pngParser"; 5 | 6 | interface PngFile { 7 | fullPath: string; 8 | relativePath: string; 9 | } 10 | 11 | export class MetadataService { 12 | private imgDir: string; 13 | private metadataDir: string; 14 | public stopProcessing = false; 15 | 16 | constructor(config: Config) { 17 | if (!config.imgDir || !config.metadataDir) { 18 | throw new Error("Required configuration missing"); 19 | } 20 | this.imgDir = config.imgDir; 21 | this.metadataDir = config.metadataDir; 22 | } 23 | 24 | updateConfig(config: Partial): void { 25 | if (config.imgDir) { 26 | this.imgDir = config.imgDir; 27 | } 28 | if (config.metadataDir) { 29 | this.metadataDir = config.metadataDir; 30 | } 31 | } 32 | 33 | async initialize(): Promise { 34 | try { 35 | let exists = false; 36 | try { 37 | await fs.access(this.metadataDir); 38 | exists = true; 39 | } catch (e) { 40 | exists = false; 41 | } 42 | 43 | if (exists) { 44 | const stats = await fs.stat(this.metadataDir); 45 | if (!stats.isDirectory()) { 46 | try { 47 | await fs.unlink(this.metadataDir); 48 | console.log(`Existing file at metadataDir was removed: ${this.metadataDir}`); 49 | } catch (unlinkError) { 50 | throw new Error(`Unable to remove file at metadataDir: ${unlinkError instanceof Error ? unlinkError.message : "Unknown error"}`); 51 | } 52 | } else { 53 | await this.clearMetadataDirectory(); 54 | } 55 | } 56 | await fs.mkdir(this.metadataDir, { recursive: true }); 57 | console.log("Metadata directory initialized:", this.metadataDir); 58 | } catch (error) { 59 | throw new Error( 60 | `Failed to initialize metadata directory: ${error instanceof Error ? error.message : "Unknown error"}` 61 | ); 62 | } 63 | } 64 | 65 | async clearMetadataDirectory(): Promise { 66 | try { 67 | const exists = await fs 68 | .access(this.metadataDir) 69 | .then(() => true) 70 | .catch(() => false); 71 | 72 | if (exists) { 73 | const deleteRecursive = async (dirPath: string): Promise => { 74 | const items = await fs.readdir(dirPath, { withFileTypes: true }); 75 | for (const item of items) { 76 | const fullPath = path.join(dirPath, item.name); 77 | if (item.isDirectory()) { 78 | await deleteRecursive(fullPath); 79 | } else { 80 | await fs.unlink(fullPath); 81 | } 82 | } 83 | await fs.rmdir(dirPath); 84 | }; 85 | 86 | await deleteRecursive(this.metadataDir); 87 | console.log("Existing metadata directory cleared"); 88 | } 89 | } catch (error) { 90 | console.error("Error clearing metadata directory:", error); 91 | throw error; 92 | } 93 | } 94 | 95 | async findPNGFiles(directory: string): Promise { 96 | console.log("Scanning directory:", directory); 97 | let pngFiles: PngFile[] = []; 98 | 99 | try { 100 | const entries = await fs.readdir(directory, { withFileTypes: true }); 101 | 102 | for (const entry of entries) { 103 | const fullPath = path.join(directory, entry.name); 104 | 105 | if (entry.isDirectory()) { 106 | console.log("Found subdirectory:", fullPath); 107 | const subDirFiles = await this.findPNGFiles(fullPath); 108 | pngFiles = pngFiles.concat(subDirFiles); 109 | } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) { 110 | console.log("Found PNG file:", fullPath); 111 | const relativePath = path.relative(this.imgDir, fullPath); 112 | pngFiles.push({ fullPath, relativePath }); 113 | } 114 | } 115 | } catch (error) { 116 | console.error("Error scanning directory:", error); 117 | } 118 | 119 | return pngFiles; 120 | } 121 | 122 | async processImage(imagePath: string): Promise<{ originalPath: string; metadata: any }> { 123 | try { 124 | console.log("Processing image:", imagePath); 125 | const parser = new PNGParser(imagePath); 126 | const metadata = await parser.parse(); 127 | 128 | const relativePath = path.relative(this.imgDir, imagePath); 129 | const relativeDir = path.dirname(relativePath); 130 | const fileName = path.basename(imagePath, ".png") + ".json"; 131 | const outputDir = path.join(this.metadataDir, relativeDir); 132 | const outputPath = path.join(outputDir, fileName); 133 | 134 | await fs.mkdir(path.dirname(outputPath), { recursive: true }); 135 | await fs.writeFile(outputPath, JSON.stringify(metadata, null, 2), "utf-8"); 136 | console.log("Saved metadata to:", outputPath); 137 | 138 | return { originalPath: imagePath, metadata }; 139 | } catch (error) { 140 | throw new Error( 141 | `Failed to process image ${path.basename(imagePath)}: ${ 142 | error instanceof Error ? error.message : "Unknown error" 143 | }` 144 | ); 145 | } 146 | } 147 | 148 | async processDirectory(progressCallback: ProgressCallback): Promise { 149 | try { 150 | this.stopProcessing = false; 151 | console.log("Starting directory scan at:", this.imgDir); 152 | const pngFiles = await this.findPNGFiles(this.imgDir); 153 | const totalFiles = pngFiles.length; 154 | console.log(`Found ${totalFiles} PNG files in total`); 155 | 156 | progressCallback({ type: "start", total: totalFiles }); 157 | 158 | const results: MetadataResult[] = []; 159 | for (let i = 0; i < pngFiles.length; i++) { 160 | if (this.stopProcessing) { 161 | throw new Error("Generation stopped"); 162 | } 163 | 164 | const file = pngFiles[i]; 165 | if (!file) continue; 166 | try { 167 | console.log(`Processing file (${i + 1}/${totalFiles}): ${file.relativePath}`); 168 | const result = await this.processImage(file.fullPath); 169 | results.push({ 170 | file: file.relativePath, 171 | success: true, 172 | metadata: result.metadata, 173 | }); 174 | 175 | progressCallback({ 176 | type: "progress", 177 | current: i + 1, 178 | total: totalFiles, 179 | file: file.relativePath, 180 | }); 181 | } catch (error) { 182 | if (this.stopProcessing) { 183 | throw new Error("Generation stopped"); 184 | } 185 | 186 | console.error(`Error processing ${file.relativePath}:`, error); 187 | results.push({ 188 | file: file.relativePath, 189 | success: false, 190 | error: error instanceof Error ? error.message : "Unknown error", 191 | }); 192 | 193 | progressCallback({ 194 | type: "progress", 195 | current: i + 1, 196 | total: totalFiles, 197 | file: file.relativePath, 198 | error: true, 199 | }); 200 | } 201 | } 202 | 203 | return results; 204 | } catch (error) { 205 | console.error("Error in processDirectory:", error); 206 | throw error; 207 | } finally { 208 | this.stopProcessing = false; 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | imgDir: string; 3 | metadataDir: string; 4 | uploadDir?: string; 5 | } 6 | 7 | export interface ImageData { 8 | imageData: string; 9 | } 10 | 11 | export interface MetadataResult { 12 | file: string; 13 | success: boolean; 14 | metadata?: any; 15 | error?: string; 16 | } 17 | 18 | interface ProgressStart { 19 | type: "start"; 20 | total: number; 21 | } 22 | 23 | interface ProgressUpdate { 24 | type: "progress"; 25 | current: number; 26 | total: number; 27 | file: string; 28 | error?: boolean; 29 | } 30 | 31 | interface ProgressComplete { 32 | type: "complete"; 33 | total?: number; 34 | successful?: number; 35 | failed?: number; 36 | details?: any[]; 37 | stopped?: boolean; 38 | } 39 | 40 | export type ProgressData = ProgressStart | ProgressUpdate | ProgressComplete; 41 | export type ProgressCallback = (progress: ProgressData) => void; 42 | 43 | export interface PNGChunk { 44 | length: number; 45 | type: string; 46 | data: Buffer; 47 | crc: number; 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | 3 | export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction): void { 4 | console.error("Global error handler caught:", err); 5 | res.status(500).json({ 6 | error: "Internal server error", 7 | message: err instanceof Error ? err.message : String(err), 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/pngParser.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import path from "path"; 3 | import type { PNGChunk } from "../types"; 4 | 5 | export class PNGParser { 6 | private filePath: string; 7 | 8 | constructor(filePath: string) { 9 | this.filePath = filePath; 10 | } 11 | 12 | async parse(): Promise { 13 | try { 14 | const data = await fs.readFile(this.filePath); 15 | return this.extractMetadata(data); 16 | } catch (error) { 17 | throw new Error( 18 | `Failed to parse PNG file: ${error instanceof Error ? error.message : "Unknown error"}` 19 | ); 20 | } 21 | } 22 | 23 | private extractMetadata(data: Buffer): any { 24 | if (!this.isPNG(data)) { 25 | throw new Error("Invalid PNG format"); 26 | } 27 | 28 | const chunks = this.parseChunks(data); 29 | const iTXtChunk = chunks.find((chunk) => chunk.type === "iTXt"); 30 | 31 | if (!iTXtChunk) { 32 | return { 33 | timestamp: new Date().toISOString(), 34 | filename: path.basename(this.filePath), 35 | metadata: {}, 36 | }; 37 | } 38 | 39 | try { 40 | const metadata = this.parseITXtChunk(iTXtChunk.data); 41 | return { 42 | ...JSON.parse(metadata), 43 | timestamp: new Date().toISOString(), 44 | filename: path.basename(this.filePath), 45 | }; 46 | } catch (error) { 47 | console.error("Raw iTXt chunk data:", iTXtChunk.data.toString("utf-8")); 48 | throw new Error( 49 | `Failed to parse metadata: ${error instanceof Error ? error.message : "Unknown error"}` 50 | ); 51 | } 52 | } 53 | 54 | private isPNG(data: Buffer): boolean { 55 | const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); 56 | return data.slice(0, 8).equals(signature); 57 | } 58 | 59 | private parseChunks(data: Buffer): PNGChunk[] { 60 | const chunks: PNGChunk[] = []; 61 | let offset = 8; 62 | 63 | while (offset < data.length) { 64 | const length = data.readUInt32BE(offset); 65 | const type = data.toString("ascii", offset + 4, offset + 8); 66 | const chunkData = data.slice(offset + 8, offset + 8 + length); 67 | const crc = data.readUInt32BE(offset + 8 + length); 68 | chunks.push({ length, type, data: chunkData, crc }); 69 | offset += 12 + length; 70 | if (type === "IEND") break; 71 | } 72 | 73 | return chunks; 74 | } 75 | 76 | private parseITXtChunk(data: Buffer): string { 77 | let pos = 0; 78 | 79 | while (data[pos] !== 0) pos++; 80 | pos++; 81 | 82 | pos += 2; 83 | 84 | while (data[pos] !== 0) pos++; 85 | pos++; 86 | 87 | while (data[pos] !== 0) pos++; 88 | pos++; 89 | 90 | const textData = data.slice(pos); 91 | return textData.toString("utf-8"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom", 8 | "dom.iterable", 9 | "esnext", 10 | "es2021.string" 11 | ], 12 | "moduleResolution": "node", 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": ["src/*"] 16 | }, 17 | "resolveJsonModule": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "strict": true, 21 | "noImplicitAny": true, 22 | "noImplicitThis": true, 23 | "alwaysStrict": true, 24 | "strictNullChecks": true, 25 | "strictFunctionTypes": true, 26 | "strictBindCallApply": true, 27 | "strictPropertyInitialization": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noImplicitReturns": true, 31 | "noFallthroughCasesInSwitch": true, 32 | "noUncheckedIndexedAccess": true, 33 | "noPropertyAccessFromIndexSignature": true, 34 | "outDir": "./dist", 35 | "rootDir": "./", 36 | "sourceMap": true, 37 | "declaration": true, 38 | "declarationMap": true, 39 | "allowJs": true, 40 | "checkJs": false, 41 | "skipLibCheck": true, 42 | "forceConsistentCasingInFileNames": true, 43 | "isolatedModules": true, 44 | "experimentalDecorators": true, 45 | "emitDecoratorMetadata": true, 46 | "types": [ 47 | "node", 48 | "express" 49 | ], 50 | "typeRoots": [ 51 | "./node_modules/@types", 52 | "./src/types" 53 | ] 54 | }, 55 | "include": [ 56 | "src/**/*", 57 | "electron/**/*", 58 | "*.json", 59 | "VERSION" 60 | ], 61 | "exclude": [ 62 | "node_modules", 63 | "dist", 64 | "build", 65 | "coverage", 66 | "public" 67 | ], 68 | "ts-node": { 69 | "files": true, 70 | "transpileOnly": true, 71 | "compilerOptions": { 72 | "module": "commonjs" 73 | } 74 | } 75 | } 76 | --------------------------------------------------------------------------------