├── src ├── types │ ├── Point.ts │ ├── StoredPortal.ts │ ├── Portal.ts │ ├── StoredPortalStore.ts │ └── ExitInfo.ts ├── metadata.ts ├── utils │ ├── pointUtils.ts │ ├── mapXZ.ts │ ├── portalStoreUtils.ts │ ├── portalUtils.ts │ └── getExits.ts ├── styles │ └── globals.css ├── components │ ├── FooterLink.tsx │ ├── ExitListing.tsx │ ├── Footer.tsx │ ├── ShowAllToggle.tsx │ ├── PortalName.tsx │ ├── box.tsx │ ├── CoordInput.tsx │ ├── StoreProvider.tsx │ ├── mapping.tsx │ ├── BooleanButtons.tsx │ ├── MainHeading.tsx │ ├── CompResult.tsx │ ├── tool.tsx │ ├── ConnectionList.tsx │ ├── FileIO.tsx │ ├── PortalList.tsx │ ├── PortalInput.tsx │ └── visualizer.tsx ├── pages │ ├── _app.tsx │ ├── index.tsx │ └── _document.tsx └── store │ ├── hooks.ts │ ├── selectExitMaps.ts │ ├── optionsSlice.ts │ ├── index.ts │ └── portalSlice.ts ├── postcss.config.mjs ├── next.config.ts ├── tailwind.config.ts ├── README.md ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json └── package.json /src/types/Point.ts: -------------------------------------------------------------------------------- 1 | export default interface Point { 2 | x: number; 3 | y: number; 4 | z: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/StoredPortal.ts: -------------------------------------------------------------------------------- 1 | import Point from "./Point"; 2 | 3 | export default interface StoredPortal extends Point { 4 | name: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/Portal.ts: -------------------------------------------------------------------------------- 1 | import StoredPortal from "./StoredPortal"; 2 | 3 | export default interface Portal extends StoredPortal { 4 | isNether: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | const metadata = { 2 | title: "Nether Link", 3 | description: "Manage Minecraft Nether portal positions.", 4 | }; 5 | 6 | export default metadata; 7 | -------------------------------------------------------------------------------- /src/utils/pointUtils.ts: -------------------------------------------------------------------------------- 1 | import Point from "@/types/Point"; 2 | 3 | export function showPoint(point: Point) { 4 | return `${point.x}, ${point.y}, ${point.z}`; 5 | } 6 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/types/StoredPortalStore.ts: -------------------------------------------------------------------------------- 1 | import StoredPortal from "./StoredPortal"; 2 | 3 | export default interface StoredPortalStore { 4 | overworld: StoredPortal[]; 5 | nether: StoredPortal[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | color: #fff; 7 | background: #222; 8 | } 9 | 10 | input { 11 | background: transparent; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/ExitInfo.ts: -------------------------------------------------------------------------------- 1 | import Point from "./Point"; 2 | import Portal from "./Portal"; 3 | 4 | export default interface ExitInfo { 5 | portal: Portal; 6 | ideal: Point; 7 | closest: [string, Portal, number] | null; 8 | nearby: [string, Portal, number][]; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/mapXZ.ts: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | 3 | export default function mapXZ(portals: Portal[], op: (xz: number) => number) { 4 | return portals.map((portal) => ({ 5 | ...portal, 6 | x: op(portal.x), 7 | z: op(portal.z), 8 | })); 9 | } 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | output: "export", 5 | assetPrefix: "./", 6 | trailingSlash: true, 7 | reactStrictMode: true, 8 | images: { 9 | unoptimized: true, 10 | }, 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /src/components/FooterLink.tsx: -------------------------------------------------------------------------------- 1 | export default function FooterLink({ 2 | href, 3 | children, 4 | }: Readonly<{ 5 | href: string; 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector, useStore } from "react-redux"; 2 | import type { AppDispatch, AppStore, RootState } from "."; 3 | 4 | export const useAppDispatch = useDispatch.withTypes(); 5 | export const useAppSelector = useSelector.withTypes(); 6 | export const useAppStore = useStore.withTypes(); 7 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/Footer"; 2 | import MainHeading from "@/components/MainHeading"; 3 | import StoreProvider from "@/components/StoreProvider"; 4 | import Tool from "@/components/Tool"; 5 | 6 | export default function ToolPage() { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import metadata from "@/metadata"; 2 | import { Html, Head, Main, NextScript } from "next/document"; 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | {metadata.title} 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /src/components/ExitListing.tsx: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import PortalName from "./PortalName"; 3 | 4 | export default function ExitListing({ 5 | portal, 6 | dist, 7 | }: Readonly<{ 8 | portal: Portal; 9 | dist: number; 10 | }>) { 11 | return ( 12 | 13 | {" => "} {" "} 14 | 15 | ({Math.round(dist * 10) / 10} block offset) 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Box from "./Box"; 2 | import FooterLink from "./FooterLink"; 3 | 4 | export default function Footer() { 5 | return ( 6 | 7 | Created by{" "} 8 | 9 | Brandon Fowler 10 | 11 | .{" "} 12 | 13 | View code on GitHub 14 | 15 | . 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nether Link 2 | 3 | ## Manage Minecraft Nether Portal Positions 4 | 5 | Nether Link is a web application that provides an easy interface to manage the relative position of Nether portals and which portals they link to. View the app online at https://www.brandonfowler.me/nether-link/. 6 | 7 | ## Development 8 | 9 | To install dependencies, run `npm install`. To launch the development server, run `npm run dev`. To make a build of Nether Link, run `npm run build`. Use the commands `npm run lint` and `npm run fix` to view and fix linting issues respectively. 10 | -------------------------------------------------------------------------------- /src/components/ShowAllToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/store/hooks"; 2 | import BooleanButtons from "./BooleanButtons"; 3 | import { set } from "@/store/optionsSlice"; 4 | 5 | export default function ShowAllToggle() { 6 | const showAll = useAppSelector((state) => state.options.showAll); 7 | const dispatch = useAppDispatch(); 8 | 9 | return ( 10 | dispatch(set({ key: "showAll", value }))} 15 | /> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/PortalName.tsx: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import { showPoint } from "@/utils/pointUtils"; 3 | import { showPortal } from "@/utils/portalUtils"; 4 | 5 | export default function PortalName({ portal }: Readonly<{ portal: Portal }>) { 6 | const color = portal.isNether ? "text-red-400" : "text-green-300"; 7 | 8 | if (!portal.name) { 9 | return {showPortal(portal)}; 10 | } 11 | 12 | return ( 13 | 14 | {portal.name}{" "} 15 | ({showPoint(portal)}) 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.config({ 14 | extends: [ 15 | "next/core-web-vitals", 16 | "next/typescript", 17 | "plugin:prettier/recommended", 18 | ], 19 | rules: { 20 | "@next/next/no-title-in-document-head": "off", 21 | }, 22 | }), 23 | ]; 24 | 25 | export default eslintConfig; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/components/box.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function Box({ 4 | className, 5 | level, 6 | title, 7 | children, 8 | }: Readonly<{ 9 | className?: string; 10 | level?: 1 | 2; 11 | title?: string; 12 | children: ReactNode; 13 | }>) { 14 | const HeadingTag = `h${level ?? 2}` as const; 15 | const headingSize = level == 1 ? "text-3xl" : "text-lg"; 16 | 17 | return ( 18 |
19 | {title && ( 20 | 21 | {title} 22 | 23 | )} 24 | {children} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/store/selectExitMaps.ts: -------------------------------------------------------------------------------- 1 | import getExits from "@/utils/getExits"; 2 | import { createSelector } from "@reduxjs/toolkit"; 3 | import { RootState } from "."; 4 | 5 | const inputSelectors = [ 6 | (state: RootState) => state.portals.overworld, 7 | (state: RootState) => state.portals.nether, 8 | ]; 9 | 10 | export const selectOverworldExits = createSelector( 11 | inputSelectors, 12 | (overworld, nether) => getExits(overworld, nether), 13 | ); 14 | 15 | export const selectNetherExits = createSelector( 16 | inputSelectors, 17 | (overworld, nether) => getExits(nether, overworld), 18 | ); 19 | 20 | export const portalTypeMap = { 21 | overworld: selectOverworldExits, 22 | nether: selectNetherExits, 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/CoordInput.tsx: -------------------------------------------------------------------------------- 1 | export default function CoordInput({ 2 | label, 3 | value, 4 | onChange, 5 | }: Readonly<{ 6 | label: string; 7 | value: number; 8 | onChange: (newValue: number) => void; 9 | }>) { 10 | function inputChanged(e: React.FormEvent) { 11 | const value = (e.target as HTMLInputElement).valueAsNumber; 12 | 13 | if (Number.isNaN(value)) return; 14 | 15 | onChange(value); 16 | } 17 | 18 | return ( 19 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "layout.tsxa"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/portalStoreUtils.ts: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import StoredPortal from "@/types/StoredPortal"; 3 | import { nextPortalId } from "./portalUtils"; 4 | 5 | export function toSaveable(list: Record): StoredPortal[] { 6 | return Object.entries(list).map(([, portal]) => ({ 7 | x: portal.x, 8 | y: portal.y, 9 | z: portal.z, 10 | name: portal.name, 11 | })); 12 | } 13 | 14 | export function loadArray( 15 | storedArr: StoredPortal[], 16 | isNether: boolean, 17 | ): Record { 18 | const portalList: Record = {}; 19 | 20 | for (const storedPortal of storedArr) { 21 | portalList[nextPortalId()] = { ...storedPortal, isNether }; 22 | } 23 | 24 | return portalList; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/StoreProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import { Provider } from "react-redux"; 5 | import { PersistGate } from "redux-persist/integration/react"; 6 | import Box from "./Box"; 7 | import { makeStore } from "@/store"; 8 | 9 | export default function StoreProvider({ 10 | children, 11 | }: Readonly<{ children: React.ReactNode }>) { 12 | const storeRef = useRef | null>(null); 13 | 14 | if (!storeRef.current) { 15 | storeRef.current = makeStore(); 16 | } 17 | 18 | return ( 19 | 20 | Loading...} 22 | persistor={storeRef.current.persistor} 23 | > 24 | {children} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/mapping.tsx: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import PortalName from "./PortalName"; 3 | 4 | export default function Mapping({ 5 | fromPortal, 6 | toPortal, 7 | bidirectional, 8 | reverse, 9 | }: Readonly<{ 10 | fromPortal?: Portal; 11 | toPortal?: Portal; 12 | bidirectional?: boolean; 13 | reverse?: boolean; 14 | }>) { 15 | return ( 16 | <> 17 | 18 | {fromPortal ? : New portal} 19 | 20 | {bidirectional || !reverse ? "<" : ""} 21 | = 22 | {bidirectional || reverse ? ">" : ""} 23 | 24 | {toPortal ? : New portal} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/BooleanButtons.tsx: -------------------------------------------------------------------------------- 1 | export default function BooleanButtons({ 2 | trueText, 3 | falseText, 4 | value, 5 | onChange, 6 | }: Readonly<{ 7 | trueText: string; 8 | falseText: string; 9 | value: boolean; 10 | onChange: (newValue: boolean) => void; 11 | }>) { 12 | const baseClass = "border border-gray-500 px-2 py-1"; 13 | const activeClass = "bg-red-950"; 14 | 15 | const falseClass = `${baseClass} ${value ? "" : activeClass}`; 16 | const trueClass = `${baseClass} ${value ? activeClass : ""} border-l-0`; 17 | 18 | return ( 19 |
20 | 23 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/store/optionsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { persistReducer } from "redux-persist"; 3 | import storage from "redux-persist/lib/storage"; 4 | 5 | export interface OptionsState { 6 | showAll: boolean; 7 | } 8 | 9 | export const optionsSlice = createSlice({ 10 | name: "options", 11 | initialState: { 12 | showAll: false, 13 | }, 14 | reducers: { 15 | set: ( 16 | state, 17 | action: PayloadAction<{ 18 | key: keyof OptionsState; 19 | value: OptionsState[typeof action.payload.key]; 20 | }>, 21 | ) => { 22 | state[action.payload.key] = action.payload.value; 23 | }, 24 | }, 25 | }); 26 | 27 | export const { set } = optionsSlice.actions; 28 | 29 | export default persistReducer( 30 | { 31 | key: `nether-link-${optionsSlice.name}`, 32 | storage, 33 | }, 34 | optionsSlice.reducer, 35 | ); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nether-link", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "lint": "next lint", 9 | "fix": "next lint --fix" 10 | }, 11 | "dependencies": { 12 | "@reduxjs/toolkit": "^2.5.1", 13 | "next": "15.1.6", 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0", 16 | "react-redux": "^9.2.0", 17 | "redux-persist": "^6.0.0" 18 | }, 19 | "devDependencies": { 20 | "@eslint/eslintrc": "^3", 21 | "@types/node": "^20", 22 | "@types/react": "^19", 23 | "@types/react-dom": "^19", 24 | "eslint": "^9", 25 | "eslint-config-next": "15.1.6", 26 | "eslint-config-prettier": "^10.0.1", 27 | "eslint-plugin-prettier": "^5.2.3", 28 | "postcss": "^8", 29 | "prettier": "3.5.0", 30 | "tailwindcss": "^3.4.1", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/MainHeading.tsx: -------------------------------------------------------------------------------- 1 | import metadata from "@/metadata"; 2 | import Box from "./Box"; 3 | 4 | export default function MainHeading() { 5 | return ( 6 |
7 | 8 |

{metadata.description}

9 |
10 |
11 | 12 | 19 | 26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import portalReducer from "./portalSlice"; 3 | import optionsReducer from "./optionsSlice"; 4 | import { 5 | FLUSH, 6 | PAUSE, 7 | PERSIST, 8 | persistStore, 9 | PURGE, 10 | REGISTER, 11 | REHYDRATE, 12 | } from "redux-persist"; 13 | 14 | export const makeStore = () => { 15 | const store = configureStore({ 16 | reducer: { 17 | portals: portalReducer, 18 | options: optionsReducer, 19 | }, 20 | middleware: (getDefaultMiddleware) => 21 | getDefaultMiddleware({ 22 | serializableCheck: { 23 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 24 | }, 25 | }), 26 | }); 27 | 28 | return { store, persistor: persistStore(store) }; 29 | }; 30 | 31 | export type AppStore = ReturnType["store"]; 32 | export type RootState = ReturnType; 33 | export type AppDispatch = AppStore["dispatch"]; 34 | -------------------------------------------------------------------------------- /src/components/CompResult.tsx: -------------------------------------------------------------------------------- 1 | import ExitInfo from "@/types/ExitInfo"; 2 | import ExitListing from "./ExitListing"; 3 | import { showPoint } from "@/utils/pointUtils"; 4 | import { useAppSelector } from "@/store/hooks"; 5 | 6 | export default function CompResult({ 7 | exitInfo: { ideal, closest, nearby }, 8 | }: Readonly<{ 9 | exitInfo: ExitInfo; 10 | }>) { 11 | const showAll = useAppSelector((state) => state.options.showAll); 12 | 13 | if (!closest) { 14 | return ( 15 |
16 | {"=>"} New portal around {showPoint(ideal)} 17 |
18 | ); 19 | } 20 | 21 | if (!showAll) { 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | nearby.sort((a, b) => { 30 | return a[2] - b[2]; 31 | }); 32 | 33 | return ( 34 |
    35 | {nearby.map(([id, portal, dist]) => { 36 | return ( 37 |
  • 38 | 39 |
  • 40 | ); 41 | })} 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/portalUtils.ts: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import { showPoint } from "./pointUtils"; 3 | import Point from "@/types/Point"; 4 | 5 | let lastId = 0; 6 | 7 | export function nextPortalId() { 8 | lastId++; 9 | return `p-${lastId}`; 10 | } 11 | 12 | export function makeBlankPortal(isNether = false): Portal { 13 | return { 14 | x: 0, 15 | y: 0, 16 | z: 0, 17 | name: "", 18 | isNether, 19 | }; 20 | } 21 | 22 | function overworldToNether(c: number) { 23 | return Math.floor(c / 8); 24 | } 25 | 26 | function netherToOverworld(c: number) { 27 | return c * 8; 28 | } 29 | 30 | export function getIdealExit(portal: Portal) { 31 | if (portal.isNether) { 32 | return { 33 | x: netherToOverworld(portal.x), 34 | y: portal.y, 35 | z: netherToOverworld(portal.z), 36 | }; 37 | } 38 | 39 | return { 40 | x: overworldToNether(portal.x), 41 | y: portal.y, 42 | z: overworldToNether(portal.z), 43 | }; 44 | } 45 | 46 | export function getOverworldPos(portal: Portal): Point { 47 | return portal.isNether ? getIdealExit(portal) : portal; 48 | } 49 | 50 | export function showPortal(portal: Portal) { 51 | const coordStr = showPoint(portal); 52 | 53 | return portal.name ? `${portal.name} (${coordStr})` : coordStr; 54 | } 55 | -------------------------------------------------------------------------------- /src/components/tool.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ConnectionList from "@/components/ConnectionList"; 4 | import Box from "@/components/Box"; 5 | import PortalList from "@/components/PortalList"; 6 | import Visualizer from "@/components/Visualizer"; 7 | import ShowAllToggle from "./ShowAllToggle"; 8 | import FileIO from "./FileIO"; 9 | 10 | export default function Tool() { 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 |
29 | 30 |
31 |
32 | 33 | 34 | 35 |
36 |
37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ConnectionList.tsx: -------------------------------------------------------------------------------- 1 | import Mapping from "./Mapping"; 2 | import { useAppSelector } from "@/store/hooks"; 3 | import { 4 | selectNetherExits, 5 | selectOverworldExits, 6 | } from "@/store/selectExitMaps"; 7 | 8 | export default function ConnectionList() { 9 | const overworldExits = useAppSelector(selectOverworldExits); 10 | const netherExits = useAppSelector(selectNetherExits); 11 | 12 | const mappings: Record = {}; 13 | const bidirectionalNether = new Set(); 14 | 15 | for (const [id, exit] of Object.entries(overworldExits)) { 16 | const bidirectional = exit.closest 17 | ? netherExits[exit.closest[0]]?.closest?.[0] == id 18 | : false; 19 | 20 | if (bidirectional) { 21 | bidirectionalNether.add(exit.closest![0]); 22 | } 23 | 24 | const key = `${id}&${exit.closest?.[0]}`; 25 | mappings[key] = ( 26 | 31 | ); 32 | } 33 | 34 | for (const [id, exit] of Object.entries(netherExits)) { 35 | if (bidirectionalNether.has(id)) continue; 36 | 37 | const key = `${exit.closest?.[0]}&${id}`; 38 | mappings[key] = ( 39 | 44 | ); 45 | } 46 | 47 | return ( 48 |
49 |
    50 | {Object.entries(mappings).map(([key, mapping]) => { 51 | return ( 52 |
  • 53 | {mapping} 54 |
  • 55 | ); 56 | })} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/getExits.ts: -------------------------------------------------------------------------------- 1 | import ExitInfo from "@/types/ExitInfo"; 2 | import Portal from "@/types/Portal"; 3 | import Point from "@/types/Point"; 4 | import { getIdealExit } from "./portalUtils"; 5 | 6 | function inRange(from: Point, to: Point, isNether: boolean) { 7 | const sqRadius = isNether ? 16 : 128; 8 | return ( 9 | Math.abs(from.x - to.x) <= sqRadius && Math.abs(from.z - to.z) <= sqRadius 10 | ); 11 | } 12 | 13 | function distance(from: Point, to: Point) { 14 | return Math.sqrt( 15 | Math.pow(from.x - to.x, 2) + 16 | Math.pow(from.y - to.y, 2) + 17 | Math.pow(from.z - to.z, 2), 18 | ); 19 | } 20 | 21 | export default function getExits( 22 | fromPortals: Record, 23 | toPortals: Record, 24 | ): Record { 25 | return Object.fromEntries( 26 | Object.entries(fromPortals).map(([id, from]) => [ 27 | id, 28 | getExit(from, toPortals), 29 | ]), 30 | ); 31 | } 32 | 33 | function getExit( 34 | fromPortal: Portal, 35 | toPortals: Record, 36 | ): ExitInfo { 37 | let minId; 38 | let minName; 39 | let minDist = Infinity; 40 | 41 | const from = getIdealExit(fromPortal); 42 | const nearby: [string, Portal, number][] = []; 43 | 44 | for (const [toId, toPortal] of Object.entries(toPortals)) { 45 | if (!inRange(from, toPortal, toPortal.isNether)) { 46 | continue; 47 | } 48 | 49 | const dist = distance(from, toPortal); 50 | 51 | if (dist < minDist) { 52 | minId = toId; 53 | minName = toPortal; 54 | minDist = dist; 55 | } 56 | 57 | nearby.push([toId, toPortal, dist]); 58 | } 59 | 60 | return { 61 | portal: fromPortal, 62 | ideal: from, 63 | closest: minName ? [minId!, minName, minDist] : null, 64 | nearby: nearby, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/FileIO.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from "@/store/hooks"; 2 | import { load } from "@/store/portalSlice"; 3 | import StoredPortalStore from "@/types/StoredPortalStore"; 4 | import { loadArray, toSaveable } from "@/utils/portalStoreUtils"; 5 | import { useCallback } from "react"; 6 | 7 | export default function FileIO() { 8 | const portals = useAppSelector((state) => state.portals); 9 | const dispatch = useAppDispatch(); 10 | 11 | const download = useCallback(() => { 12 | const a = document.createElement("a"); 13 | 14 | const storedStore = { 15 | overworld: toSaveable(portals.overworld), 16 | nether: toSaveable(portals.nether), 17 | } satisfies StoredPortalStore; 18 | const file = new Blob([JSON.stringify(storedStore, undefined, "\t")], { 19 | type: "application/json", 20 | }); 21 | 22 | a.href = URL.createObjectURL(file); 23 | a.download = "nether-links.json"; 24 | 25 | a.click(); 26 | URL.revokeObjectURL(a.href); 27 | }, [portals]); 28 | 29 | const upload = useCallback(() => { 30 | const input = document.createElement("input"); 31 | input.type = "file"; 32 | 33 | input.addEventListener("change", async (e) => { 34 | const file = (e.target as HTMLInputElement).files?.[0]; 35 | if (!file) return; 36 | 37 | const text = await file.text(); 38 | const storedStore = JSON.parse(text) as StoredPortalStore; 39 | const store = { 40 | overworld: loadArray(storedStore.overworld, false), 41 | nether: loadArray(storedStore.nether, true), 42 | }; 43 | 44 | dispatch(load({ store })); 45 | }); 46 | 47 | input.click(); 48 | }, [dispatch]); 49 | 50 | const className = "border border-gray-500 px-2 py-1 hover:bg-red-950"; 51 | 52 | return ( 53 |
54 | 57 | 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/PortalList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import PortalInput from "./PortalInput"; 3 | import { useAppDispatch, useAppSelector } from "@/store/hooks"; 4 | import { add, remove, update } from "@/store/portalSlice"; 5 | import Portal from "@/types/Portal"; 6 | import { nextPortalId } from "@/utils/portalUtils"; 7 | import { portalTypeMap } from "@/store/selectExitMaps"; 8 | 9 | export default function PortalList({ 10 | type, 11 | isNether, 12 | }: Readonly<{ 13 | type: "overworld" | "nether"; 14 | isNether: boolean; 15 | }>) { 16 | const makeNext = useCallback( 17 | () => 18 | [ 19 | nextPortalId(), 20 | { 21 | x: 0, 22 | y: 0, 23 | z: 0, 24 | name: "", 25 | isNether, 26 | }, 27 | ] as [string, Portal], 28 | [isNether], 29 | ); 30 | 31 | const [nextPortal, setNextPortal] = useState<[string, Portal]>(makeNext); 32 | const portals = useAppSelector((state) => state.portals[type]); 33 | const exits = useAppSelector(portalTypeMap[type]); 34 | const dispatch = useAppDispatch(); 35 | 36 | return ( 37 |
38 | {[...Object.entries(portals), nextPortal].map(([id, portal]) => { 39 | const isNew = id === nextPortal[0]; 40 | 41 | return ( 42 |
43 | { 48 | if (isNew) { 49 | dispatch( 50 | add({ 51 | type, 52 | id, 53 | portal: { 54 | ...portal, 55 | [prop]: value, 56 | }, 57 | }), 58 | ); 59 | 60 | setNextPortal(makeNext()); 61 | return; 62 | } 63 | 64 | dispatch(update({ type, id, prop, value })); 65 | }} 66 | portalRemoved={() => dispatch(remove({ type, id }))} 67 | /> 68 |
69 | ); 70 | })} 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/PortalInput.tsx: -------------------------------------------------------------------------------- 1 | import ExitInfo from "@/types/ExitInfo"; 2 | import CompResult from "./CompResult"; 3 | import Portal from "@/types/Portal"; 4 | import { showPoint } from "@/utils/pointUtils"; 5 | import CoordInput from "./CoordInput"; 6 | 7 | export default function PortalInput({ 8 | data, 9 | exitInfo, 10 | isNew, 11 | portalUpdated, 12 | portalRemoved, 13 | }: Readonly<{ 14 | data: Portal; 15 | exitInfo: ExitInfo; 16 | isNew?: boolean; 17 | portalUpdated: (prop: keyof Portal, value: Portal[typeof prop]) => void; 18 | portalRemoved: () => void; 19 | }>) { 20 | return ( 21 |
22 | 25 | portalUpdated("name", (e.target as HTMLInputElement).value) 26 | } 27 | title="Label" 28 | placeholder={isNew ? "Add portal..." : "Label"} 29 | className={`w-28 ${data.isNether ? "text-red-400" : "text-green-300"}`} 30 | /> 31 |
32 |
33 |
34 |
35 | portalUpdated("x", v)} 39 | /> 40 | portalUpdated("y", v)} 44 | /> 45 | portalUpdated("z", v)} 49 | /> 50 |
51 | {exitInfo && ( 52 |
53 | {data.isNether ? "Overworld" : "Nether"}:{" "} 54 | {showPoint(exitInfo.ideal)} 55 |
56 | )} 57 |
58 | 59 | {!isNew && } 60 | 61 |
62 | {exitInfo && ( 63 |
64 | 65 |
66 | )} 67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/store/portalSlice.ts: -------------------------------------------------------------------------------- 1 | import Portal from "@/types/Portal"; 2 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 3 | import { createTransform, persistReducer } from "redux-persist"; 4 | import storage from "redux-persist/lib/storage"; 5 | import { loadArray, toSaveable } from "@/utils/portalStoreUtils"; 6 | 7 | export interface PortalStoreState { 8 | overworld: Record; 9 | nether: Record; 10 | } 11 | 12 | export const portalSlice = createSlice({ 13 | name: "portals", 14 | initialState: { 15 | overworld: {}, 16 | nether: {}, 17 | } as PortalStoreState, 18 | reducers: { 19 | load: ( 20 | state, 21 | action: PayloadAction<{ 22 | store: PortalStoreState; 23 | }>, 24 | ) => { 25 | state.overworld = action.payload.store.overworld; 26 | state.nether = action.payload.store.nether; 27 | }, 28 | add: ( 29 | state, 30 | action: PayloadAction<{ 31 | type: keyof PortalStoreState; 32 | id: string; 33 | portal: Portal; 34 | }>, 35 | ) => { 36 | state[action.payload.type][action.payload.id] = action.payload.portal; 37 | }, 38 | remove: ( 39 | state, 40 | action: PayloadAction<{ 41 | type: keyof PortalStoreState; 42 | id: string; 43 | }>, 44 | ) => { 45 | delete state[action.payload.type][action.payload.id]; 46 | }, 47 | update: ( 48 | state, 49 | action: PayloadAction<{ 50 | type: keyof PortalStoreState; 51 | id: string; 52 | prop: keyof Portal; 53 | value: Portal[keyof Portal]; 54 | }>, 55 | ) => { 56 | (state[action.payload.type][action.payload.id][ 57 | action.payload.prop 58 | ] as Portal[keyof Portal]) = action.payload.value; 59 | }, 60 | }, 61 | }); 62 | 63 | export const { load, add, remove, update } = portalSlice.actions; 64 | 65 | export default persistReducer( 66 | { 67 | key: `nether-link-${portalSlice.name}`, 68 | storage, 69 | transforms: [ 70 | createTransform( 71 | (subState, key) => { 72 | switch (key) { 73 | case "overworld": 74 | case "nether": 75 | return toSaveable(subState); 76 | default: 77 | return subState; 78 | } 79 | }, 80 | (subState, key) => { 81 | switch (key) { 82 | case "overworld": 83 | return loadArray(subState, false); 84 | case "nether": 85 | return loadArray(subState, true); 86 | default: 87 | return subState; 88 | } 89 | }, 90 | { 91 | whitelist: ["overworld", "nether"], 92 | }, 93 | ), 94 | ], 95 | }, 96 | portalSlice.reducer, 97 | ); 98 | -------------------------------------------------------------------------------- /src/components/visualizer.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "@/store/hooks"; 2 | import { 3 | selectNetherExits, 4 | selectOverworldExits, 5 | } from "@/store/selectExitMaps"; 6 | import { getOverworldPos, showPortal } from "@/utils/portalUtils"; 7 | import ExitInfo from "@/types/ExitInfo"; 8 | import { useRef } from "react"; 9 | 10 | export default function Visualizer() { 11 | const exitLists = [ 12 | useAppSelector(selectOverworldExits), 13 | useAppSelector(selectNetherExits), 14 | ]; 15 | 16 | const lastExitLists = useRef[]>([]); 17 | const version = useRef(0); 18 | 19 | if (exitLists.find((exits, i) => exits !== lastExitLists.current[i])) { 20 | lastExitLists.current = exitLists; 21 | version.current++; 22 | } 23 | 24 | let minX = Infinity; 25 | let maxX = -Infinity; 26 | let minZ = Infinity; 27 | let maxZ = -Infinity; 28 | 29 | const circles = []; 30 | const lines = []; 31 | 32 | for (const exits of exitLists) { 33 | for (const [id, exit] of Object.entries(exits)) { 34 | const from = exit.portal; 35 | const fill = from.isNether ? "fill-red-500" : "fill-green-500"; 36 | const pos = getOverworldPos(from); 37 | const toPos = exit.closest && getOverworldPos(exit.closest[1]); 38 | 39 | if (pos.x < minX) minX = pos.x; 40 | if (pos.x > maxX) maxX = pos.x; 41 | if (pos.z < minZ) minZ = pos.z; 42 | if (pos.z > maxZ) maxZ = pos.z; 43 | 44 | circles.push( 45 | 46 | {showPortal(from)} 47 | , 48 | ); 49 | 50 | if (toPos) { 51 | const control = { 52 | x: (toPos.x + pos.x) / 2, 53 | z: ((toPos.z + pos.z) / 2) * (toPos.z > pos.z ? 0.998 : 1.002), 54 | }; 55 | 56 | lines.push( 57 | , 62 | ); 63 | } 64 | } 65 | } 66 | 67 | return ( 68 | 72 | 73 | 84 | 85 | 86 | 87 | {...lines} 88 | {...circles} 89 | 90 | ); 91 | } 92 | --------------------------------------------------------------------------------