feat: first commit

This commit is contained in:
Narongpol Kijrangsan 2025-02-22 21:18:41 +07:00
commit 8b0c05008e
Signed by: nkdev
GPG key ID: 6E4892640C1DB7C5
79 changed files with 30897 additions and 0 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
node_modules/

21
.eslintrc.js Normal file
View file

@ -0,0 +1,21 @@
module.exports = {
env: {
browser: true,
es6: true,
node: true
},
extends: "react-app",
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
__PATH_PREFIX__: true
},
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: 2018,
sourceType: "module"
},
plugins: ["react"]
};

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
.cache
package.json
package-lock.json
public
.vscode/

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"endOfLine": "lf",
"semi": true,
"singleQuote": false,
"tabWidth": 2
}

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM node:latest
# Install Python, make, and build dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
make \
g++ \
libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Jeff Hackshaw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
compose.yaml Normal file
View file

@ -0,0 +1,3 @@
services:
nodejs-app:
build: .

17
gatsby-browser.js Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import { Provider } from "react-redux";
import store from "./src/store/store";
import "react-vis/dist/style.css";
import "prismjs/themes/prism-tomorrow.css";
import "typeface-roboto";
import { ThemeContextProvider } from "./src/context";
export const wrapRootElement = ({ element }) => (
<ThemeContextProvider>
<Provider store={store}>{element}</Provider>
</ThemeContextProvider>
);
export const onServiceWorkerUpdateReady = () => {
window.location.reload();
};

44
gatsby-config.js Normal file
View file

@ -0,0 +1,44 @@
module.exports = {
siteMetadata: {
title: `Traveling Salesman Problem Solver`,
description: ``,
author: `@jhackshaw`
},
plugins: [
`gatsby-plugin-material-ui`,
`gatsby-plugin-react-helmet`,
{
resolve: "gatsby-plugin-google-tagmanager",
options: {
id: "GTM-PS28HLQ",
includeInDevelopment: false
}
},
{
resolve: `gatsby-source-filesystem`,
options: {
name: "content",
path: `${__dirname}/src/content`
}
},
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [`gatsby-remark-prismjs`]
}
},
{
resolve: `gatsby-plugin-manifest`,
options: {
name: `tsp visualizer`,
short_name: `tspvis`,
start_url: `/`,
background_color: `#663399`,
theme_color: `#663399`,
display: `fullscreen`,
icon: `src/images/favicon.png`
}
},
`gatsby-plugin-offline`
]
};

20
gatsby-node.js Normal file
View file

@ -0,0 +1,20 @@
exports.onCreateWebpackConfig = ({
actions: { replaceWebpackConfig },
getConfig
}) => {
const config = getConfig();
config.module.rules.push({
test: /\.worker\.js$/,
use: {
loader: "worker-loader",
options: {
inline: true
}
}
});
config.output.globalObject = "this";
replaceWebpackConfig(config);
};

17
gatsby-ssr.js Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import { ThemeContextProvider, PreSetTheme } from "./src/context";
import { Provider } from "react-redux";
import store from "./src/store/store";
import "react-vis/dist/style.css";
export const wrapRootElement = ({ element }) => {
return (
<ThemeContextProvider>
<Provider store={store}>{element}</Provider>
</ThemeContextProvider>
);
};
export const onRenderBody = ({ setPreBodyComponents }) => {
setPreBodyComponents([<PreSetTheme key="prerender-theme" />]);
};

26594
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

63
package.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "tsp-visualizer",
"private": true,
"description": "Visualizer for the traveling salesman problem",
"version": "0.1.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-brands-svg-icons": "^5.15.3",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/react-fontawesome": "^0.1.14",
"@material-ui/core": "^4.11.3",
"@material-ui/styles": "^4.11.3",
"deck.gl": "^7.3.15",
"eslint-config-react-app": "^5.2.1",
"gatsby": "^2.32.12",
"gatsby-cli": "^2.19.2",
"gatsby-plugin-google-tagmanager": "^2.11.0",
"gatsby-plugin-manifest": "^2.12.1",
"gatsby-plugin-material-ui": "^2.1.6",
"gatsby-plugin-offline": "^3.10.2",
"gatsby-plugin-react-helmet": "^3.10.0",
"gatsby-remark-prismjs": "^3.13.0",
"gatsby-source-filesystem": "^2.11.1",
"gatsby-transformer-remark": "^2.16.1",
"gh-pages": "^2.2.0",
"prismjs": "^1.23.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "^5.2.1",
"react-map-gl": "^5.3.12",
"react-redux": "^7.2.3",
"react-vis": "^1.11.7",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"typeface-roboto": "0.0.75",
"worker-loader": "^2.0.0"
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-plugin-react": "^7.23.2",
"prettier": "^1.19.1"
},
"keywords": [
"gatsby"
],
"license": "MIT",
"scripts": {
"build": "gatsby build",
"develop": "gatsby develop",
"format": "prettier --write \"**/*.{js,jsx,json,md}\"",
"start": "npm run develop",
"serve": "gatsby serve",
"clean": "gatsby clean"
},
"repository": {
"type": "git",
"url": "https://github.com/jhackshaw/tspvis"
},
"bugs": {
"url": "https://github.com/jhackshaw/tspvis/issues"
}
}

View file

@ -0,0 +1,33 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import { useAlgorithmInfo } from "../hooks";
import * as selectors from "../store/selectors";
import * as actions from "../store/actions";
import { InformationModal } from "./InformationModal";
export const AlgorithmModals = props => {
const dispatch = useDispatch();
const algorithms = useAlgorithmInfo();
const selectedAlgorithm = useSelector(selectors.selectAlgorithm);
const open = useSelector(selectors.selectAlgInfoOpen);
const onClose = () => {
dispatch(actions.toggleAlgInfoOpen());
};
return (
<>
{algorithms.map(alg => (
<InformationModal
key={alg.solverKey}
open={open && selectedAlgorithm === alg.solverKey}
onClose={onClose}
>
<div dangerouslySetInnerHTML={{ __html: alg.html }} />
</InformationModal>
))}
</>
);
};

View file

@ -0,0 +1,55 @@
import React from "react";
import { makeStyles } from "@material-ui/styles";
import {
Dialog,
DialogContent,
IconButton,
useMediaQuery,
useTheme
} from "@material-ui/core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faWindowClose } from "@fortawesome/free-solid-svg-icons";
const useStyles = makeStyles(theme => ({
closeButton: {
position: "absolute",
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500]
},
root: {
zIndex: "10000 !important"
}
}));
export const InformationModal = ({ open, onClose, children }) => {
const classes = useStyles();
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Dialog
open={open}
onClose={onClose}
fullScreen={fullScreen}
maxWidth="md"
scroll="paper"
keepMounted
disablePortal
fullWidth
classes={{ root: classes.root, paper: classes.root }}
>
<DialogContent>
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<FontAwesomeIcon icon={faWindowClose} width="0" />
</IconButton>
{children}
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,24 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import * as selectors from "../store/selectors";
import * as actions from "../store/actions";
import { useIntroductionInfo } from "../hooks";
import { InformationModal } from "./InformationModal";
export const IntroductionModal = props => {
const dispatch = useDispatch();
const introduction = useIntroductionInfo();
const open = useSelector(selectors.selectSiteInfoOpen);
const onClose = () => {
dispatch(actions.toggleSiteInfoOpen());
};
return (
<InformationModal open={open} onClose={onClose}>
<div dangerouslySetInnerHTML={{ __html: introduction }} />
</InformationModal>
);
};

23
src/components/Layout.jsx Normal file
View file

@ -0,0 +1,23 @@
import React from "react";
import { makeStyles } from "@material-ui/styles";
const useStyles = makeStyles(theme => ({
container: {
height: "100vh",
display: "flex",
overflow: "hidden",
flexDirection: "row"
},
[theme.breakpoints.down("sm")]: {
container: {
justifyContent: "flex-end",
flexDirection: "column-reverse"
}
}
}));
export const Layout = ({ children }) => {
const classes = useStyles();
return <div className={classes.container}>{children}</div>;
};

107
src/components/MapPlot.jsx Normal file
View file

@ -0,0 +1,107 @@
import React, { useRef, useImperativeHandle, useEffect, useMemo } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useMediaQuery } from "@material-ui/core";
import MapGL from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import DeckGL from '@deck.gl/react';
import { ScatterplotLayer, PathLayer } from "@deck.gl/layers";
import { LinearProgress } from "@material-ui/core";
import * as actions from "../store/actions";
import * as selectors from "../store/selectors";
import { useThemeContext } from "../context";
// not secret
const TOKEN =
"pk.eyJ1IjoiaW50cmVwaWRldiIsImEiOiJjazBpa2M5YnowMHcyM21ubzgycW8zZHJmIn0.DCO2aRA6MJweC8HN-d_cgQ";
export const MapPlot = React.forwardRef((props, ref) => {
const { children } = props;
const { muiTheme, colorMode } = useThemeContext();
const matches = useMediaQuery(muiTheme.breakpoints.down("sm"));
const mapGlRef = useRef();
const plotPoints = useSelector(selectors.selectPointsDisplay);
const plotPaths = useSelector(selectors.selectPlotPaths);
const viewport = useSelector(selectors.selectViewport);
const running = useSelector(selectors.selectRunning);
const definingPoints = useSelector(selectors.selectDefiningPoints);
const mapStyle = useMemo(() =>
colorMode === "dark"
? "mapbox://styles/mapbox/dark-v8"
: "mapbox://styles/mapbox/light-v8"
);
const dispatch = useDispatch();
useImperativeHandle(ref, () => ({
getBounds: () => {
const map = mapGlRef.current.getMap();
const { _ne, _sw } = map.getBounds();
return {
top: _ne.lat,
bottom: _sw.lat,
left: _ne.lng,
right: _sw.lng
};
}
}));
useEffect(() => {
if (matches) {
dispatch(
actions.setViewportState({
...viewport,
zoom: 2
})
);
}
}, [matches, dispatch]); // eslint-disable-line react-hooks/exhaustive-deps
const onViewportChanged = viewport => {
dispatch(actions.setViewportState(viewport));
};
const onDefinedPoint = ({ lngLat }) => {
dispatch(actions.addDefinedPoint(lngLat));
};
const layers = useMemo(() => [
new PathLayer({
id: "path-layer",
data: plotPaths,
getPath: d => d.path,
getColor: d => d.color,
pickable: true,
widthMinPixels: 4,
widthMaxPixels: 8
}),
new ScatterplotLayer({
id: "scatter-layer",
data: plotPoints,
pickable: true,
opacity: 0.8,
getFillColor: p => p.color,
radiusMinPixels: 6,
radiusMaxPixels: 8
})
], [plotPaths, plotPoints]);
return (
<MapGL
{...viewport}
ref={mapGlRef}
width="100%"
height={matches ? "50%" : "100%"}
maxPitch={0}
onViewportChange={onViewportChanged}
mapboxApiAccessToken={TOKEN}
disableTokenWarning={true}
onNativeClick={definingPoints && onDefinedPoint}
doubleClickZoom={false}
mapStyle={mapStyle}
>
{running && <LinearProgress color="secondary" />}
<DeckGL viewState={viewport} layers = {layers} />
{children}
</MapGL>
);
});

54
src/components/Menu.jsx Normal file
View file

@ -0,0 +1,54 @@
import React from "react";
import { makeStyles } from "@material-ui/styles";
import { Paper, Divider } from "@material-ui/core";
import { MenuHeader } from "./MenuHeader";
import { MenuSolverControls } from "./MenuSolverControls";
import { MenuMetrics } from "./MenuMetrics";
import { MenuPointControls } from "./MenuPointControls";
import { OtherControls } from "./OtherControls";
const useStyles = makeStyles(theme => ({
wrapper: {
overflowY: "auto",
flex: "0 0 400px",
padding: theme.spacing(2),
display: "flex",
flexDirection: "column",
flexWrap: "nowrap",
alginItems: "flex-start",
zIndex: 100
},
[theme.breakpoints.down("sm")]: {
width: "100%"
}
}));
export const Menu = ({
onStart,
onPause,
onUnPause,
onFullSpeed,
onStop,
onRandomizePoints
}) => {
const classes = useStyles();
return (
<Paper classes={{ root: classes.wrapper }}>
<MenuHeader />
<Divider />
<MenuMetrics />
<MenuSolverControls
onStart={onStart}
onPause={onPause}
onUnPause={onUnPause}
onStop={onStop}
onFullSpeed={onFullSpeed}
/>
<Divider />
<MenuPointControls onRandomizePoints={onRandomizePoints} />
<Divider />
<OtherControls />
</Paper>
);
};

View file

@ -0,0 +1,73 @@
import React from "react";
import { useDispatch } from "react-redux";
import { Grid, Typography, IconButton, Tooltip } from "@material-ui/core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faInfoCircle,
faBriefcase,
faDonate
} from "@fortawesome/free-solid-svg-icons";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { makeStyles } from "@material-ui/styles";
import { MenuSection } from "./MenuSection";
import * as actions from "../store/actions";
const useStyles = makeStyles(theme => ({
root: {
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3)
},
title: {
fontSize: "1.2rem"
}
}));
export const MenuHeader = props => {
const classes = useStyles();
const dispatch = useDispatch();
const onOpenSiteInfo = () => {
dispatch(actions.toggleSiteInfoOpen());
};
return (
<MenuSection>
<Grid container justify="space-between" alignItems="center">
<Typography
gutterBottom
display="inline"
variant="button"
component="h1"
classes={{ root: classes.title }}
>
<FontAwesomeIcon icon={faBriefcase} width="0" /> TSPVIS
</Typography>
<Typography gutterBottom display="inline" color="textSecondary">
<Tooltip title="Source code">
<IconButton
target="_blank"
href="https://github.com/jhackshaw/tspvis"
>
<FontAwesomeIcon icon={faGithub} size="xs" width="0" />
</IconButton>
</Tooltip>
<Tooltip title="General site information">
<IconButton onClick={onOpenSiteInfo} edge="end">
<FontAwesomeIcon icon={faInfoCircle} size="xs" width="0" />
</IconButton>
</Tooltip>
</Typography>
</Grid>
<Typography variant="subtitle2" color="textSecondary">
Visualize algorithms for the traveling salesman problem. Use the
controls below to plot points, choose an algorithm, and control
execution.
<br />
(Hint: try a construction alogorithm followed by an improvement
algorithm)
</Typography>
</MenuSection>
);
};

View file

@ -0,0 +1,38 @@
import React from "react";
import { makeStyles } from "@material-ui/styles";
import { Grid, Typography } from "@material-ui/core";
const useStyles = makeStyles(theme => ({
item: {
margin: `${theme.spacing(1.5)}px 0`
}
}));
export const MenuItem = ({ children, title = "", row = false }) => {
const classes = useStyles();
return (
<div className={classes.item}>
<Grid
item
container
direction={row ? "row" : "column"}
alignItems={row ? "center" : "flex-start"}
>
{title && (
<Grid item xs={12}>
<Typography
gutterBottom
color="textSecondary"
variant="button"
component="div"
>
{title}
</Typography>
</Grid>
)}
{children}
</Grid>
</div>
);
};

View file

@ -0,0 +1,122 @@
import React, { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import { Grid, Typography } from "@material-ui/core";
import * as selectors from "../store/selectors";
import { MenuSection } from "./MenuSection";
import { MenuItem } from "./MenuItem";
import { makeStyles } from "@material-ui/styles";
const useStyles = makeStyles(theme => ({
grow: {
flexGrow: 1,
paddingRight: theme.spacing(1)
},
unit: {
flexShrink: 0,
width: "2rem"
}
}));
export const MenuMetrics = props => {
const classes = useStyles();
const best = useSelector(selectors.selectBestCostDisplay);
const evaluating = useSelector(selectors.selectEvaluatingCostDisplay);
const startedRunningAt = useSelector(selectors.selectStartedRunningAt);
const [runningFor, setRunningFor] = useState(0);
useEffect(() => {
if (startedRunningAt) {
const interval = setInterval(() => {
setRunningFor(Math.floor((Date.now() - startedRunningAt) / 1000));
}, 1000);
return () => clearInterval(interval);
}
}, [startedRunningAt]);
return (
<MenuSection>
<MenuItem row>
<Grid item container justify="space-between">
<Typography
display="inline"
variant="button"
color="textSecondary"
component="div"
>
Current Best:{" "}
</Typography>
<Typography
classes={{ root: classes.grow }}
align="right"
display="inline"
variant="button"
>
{best}
</Typography>
<Typography
classes={{ root: classes.unit }}
align="right"
display="inline"
variant="button"
>
km
</Typography>
</Grid>
<Grid item container justify="space-between">
<Typography
display="inline"
variant="button"
color="textSecondary"
component="div"
>
Evaluating:{" "}
</Typography>
<Typography
classes={{ root: classes.grow }}
align="right"
display="inline"
variant="button"
>
{evaluating}
</Typography>
<Typography
classes={{ root: classes.unit }}
align="right"
display="inline"
variant="button"
>
km
</Typography>
</Grid>
<Grid item container justify="space-between">
<Typography
display="inline"
variant="button"
color="textSecondary"
component="div"
>
Running For:{" "}
</Typography>
<Typography
classes={{ root: classes.grow }}
align="right"
display="inline"
variant="button"
>
{runningFor || ""}
</Typography>
<Typography
classes={{ root: classes.unit }}
align="right"
display="inline"
variant="button"
>
s
</Typography>
</Grid>
</MenuItem>
</MenuSection>
);
};

View file

@ -0,0 +1,141 @@
import React, { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
ButtonGroup,
Button,
Slider,
Grid,
Typography,
makeStyles
} from "@material-ui/core";
import {
faRandom,
faSave,
faMousePointer,
faMapMarked
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MenuSection } from "./MenuSection";
import { MenuItem } from "./MenuItem";
import * as selectors from "../store/selectors";
import * as actions from "../store/actions";
const useStyles = makeStyles(theme => ({
grow: {
flexGrow: 1
}
}));
// [0, 1/2, 1, 3, 12]
let cache = ["1e+0", "1e+0"];
const possRoutes = n => {
if (n <= 2) {
return "1e+0";
}
if (typeof cache[n - 1] !== "undefined") {
return cache[n - 1];
}
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
cache[i] = (result / 2).toExponential(3);
}
return cache[n - 1];
};
export const MenuPointControls = ({ onRandomizePoints }) => {
const classes = useStyles();
const [possiblePaths, setPossiblePaths] = useState("0");
const dispatch = useDispatch();
const pointCount = useSelector(selectors.selectPointCount);
const running = useSelector(selectors.selectRunning);
const definingPoints = useSelector(selectors.selectDefiningPoints);
const onDefaultMap = () => {
dispatch(actions.setDefaultMap());
};
const onToggleDefiningPoints = () => {
const action = definingPoints
? actions.stopDefiningPoints()
: actions.startDefiningPoints();
dispatch(action);
};
const onPointCountChange = (_, newCount) => {
dispatch(actions.setPointCount(newCount));
};
useEffect(() => {
setPossiblePaths(possRoutes(pointCount));
}, [pointCount]);
const [num, exp] = possiblePaths.split("e+");
return (
<MenuSection>
<MenuItem title="Points">
<ButtonGroup
fullWidth
variant="outlined"
color="secondary"
size="large"
disabled={running}
>
<Button
onClick={onRandomizePoints}
disabled={definingPoints || pointCount < 3}
>
<FontAwesomeIcon icon={faRandom} width="0" />
</Button>
<Button onClick={onToggleDefiningPoints}>
<FontAwesomeIcon
icon={definingPoints ? faSave : faMousePointer}
width="0"
/>
</Button>
<Button disabled={definingPoints} onClick={onDefaultMap}>
<FontAwesomeIcon icon={faMapMarked} width="0" />
</Button>
</ButtonGroup>
</MenuItem>
<MenuItem title="Number of random points">
<Slider
value={pointCount}
onChange={onPointCountChange}
step={1}
min={3}
max={200}
valueLabelDisplay="auto"
color="secondary"
disabled={running || definingPoints}
/>
</MenuItem>
<MenuItem row>
<Grid item container justify="space-between">
<Typography
display="inline"
variant="button"
color="textSecondary"
component="div"
>
Possible Paths:{" "}
</Typography>
<Typography
classes={{ root: classes.grow }}
align="right"
display="inline"
component="span"
>
{num} x 10<sup>{exp}</sup>
</Typography>
</Grid>
</MenuItem>
</MenuSection>
);
};

View file

@ -0,0 +1,26 @@
import React from "react";
import { makeStyles } from "@material-ui/styles";
import { Grid } from "@material-ui/core";
const useStyles = makeStyles(theme => ({
section: {
padding: theme.spacing(2),
// backgroundColor: ({ highlight = false }) =>
// highlight ? theme.palette.grey[100] : theme.palette.paper,
border: ({ highlight = false }) =>
highlight ? `2px solid ${theme.palette.secondary.main}` : "none",
borderRadius: "10px"
}
}));
export const MenuSection = ({ children, ...rest }) => {
const classes = useStyles(rest);
return (
<div className={classes.section}>
<Grid container direction="column" wrap="nowrap">
{children}
</Grid>
</div>
);
};

View file

@ -0,0 +1,236 @@
import React from "react";
import {
ButtonGroup,
Button,
Slider,
Select,
ListSubheader,
MenuItem as SelectItem,
Typography,
Switch,
Grid,
IconButton
} from "@material-ui/core";
import { useDispatch, useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faStop,
faRedo,
faQuestion,
faFastForward,
faPause
} from "@fortawesome/free-solid-svg-icons";
import { MenuSection } from "./MenuSection";
import { MenuItem } from "./MenuItem";
import { useAlgorithmInfo } from "../hooks";
import * as actions from "../store/actions";
import * as selectors from "../store/selectors";
export const MenuSolverControls = ({
onStart,
onPause,
onUnPause,
onFullSpeed,
onStop
}) => {
const dispatch = useDispatch();
const algorithms = useAlgorithmInfo();
const selectedAlgorithm = useSelector(selectors.selectAlgorithm);
const delay = useSelector(selectors.selectDelay);
const evaluatingDetailLevel = useSelector(
selectors.selectEvaluatingDetailLevel
);
const maxEvaluatingDetailLevel = useSelector(
selectors.selectMaxEvaluatingDetailLevel
);
const showBestPath = useSelector(selectors.selectShowBestPath);
const running = useSelector(selectors.selectRunning);
const fullSpeed = useSelector(selectors.selectFullSpeed);
const paused = useSelector(selectors.selectPaused);
const definingPoints = useSelector(selectors.selectDefiningPoints);
const onAlgorithmChange = event => {
event.persist();
onStop();
const solverKey = event.target.value;
const { defaults } = algorithms.find(alg => alg.solverKey === solverKey);
dispatch(actions.setAlgorithm(solverKey, defaults));
};
const onDelayChange = (_, newDelay) => {
dispatch(actions.setDelay(newDelay));
};
const onEvaluatingDetailLevelChange = (onLevel, offLevel) => event => {
const level = event.target.checked ? onLevel : offLevel;
dispatch(actions.setEvaluatingDetailLevel(level));
};
const onShowBestPathChange = event => {
dispatch(actions.setShowBestPath(event.target.checked));
};
const onReset = () => {
onStop();
dispatch(actions.resetSolverState());
};
const onShowAlgInfo = () => {
dispatch(actions.toggleAlgInfoOpen());
};
return (
<>
<MenuSection highlight>
<MenuItem title="Algorithm">
<Grid container alignItems="center">
<Grid item xs={11}>
<Select
value={selectedAlgorithm}
onChange={onAlgorithmChange}
disabled={running || paused || definingPoints}
variant="outlined"
fullWidth
margin="dense"
>
<ListSubheader>Heuristic Construction</ListSubheader>
{algorithms
.filter(alg => alg.type === "heuristic-construction")
.map(alg => (
<SelectItem value={alg.solverKey} key={alg.solverKey}>
{alg.friendlyName}
</SelectItem>
))}
<ListSubheader>Heuristic Improvement</ListSubheader>
{algorithms
.filter(alg => alg.type === "heuristic-improvement")
.map(alg => (
<SelectItem value={alg.solverKey} key={alg.solverKey}>
{alg.friendlyName}
</SelectItem>
))}
<ListSubheader>Exhaustive</ListSubheader>
{algorithms
.filter(alg => alg.type === "exhaustive")
.map(alg => (
<SelectItem value={alg.solverKey} key={alg.solverKey}>
{alg.friendlyName}
</SelectItem>
))}
</Select>
</Grid>
<Grid item xs={1}>
<Typography align="right" color="textSecondary">
<IconButton edge="end" onClick={onShowAlgInfo}>
<FontAwesomeIcon icon={faQuestion} size="xs" />
</IconButton>
</Typography>
</Grid>
</Grid>
</MenuItem>
<MenuItem title="Controls">
<ButtonGroup
fullWidth
variant="outlined"
color="secondary"
size="large"
>
<Button
onClick={paused ? onUnPause : running ? onPause : onStart}
disabled={definingPoints || fullSpeed}
>
<FontAwesomeIcon
icon={paused ? faPlay : running ? faPause : faPlay}
width="0"
/>
</Button>
<Button
onClick={paused ? onStop : onFullSpeed}
disabled={(!running && !paused) || definingPoints || fullSpeed}
>
<FontAwesomeIcon
icon={paused ? faStop : faFastForward}
width="0"
/>
</Button>
<Button onClick={onReset} disabled={running || definingPoints}>
<FontAwesomeIcon icon={faRedo} width="0" />
</Button>
</ButtonGroup>
</MenuItem>
<MenuItem title="Delay">
<Slider
value={delay}
onChange={onDelayChange}
step={25}
min={0}
max={250}
valueLabelDisplay="auto"
color="secondary"
disabled={definingPoints || fullSpeed}
/>
</MenuItem>
</MenuSection>
<MenuSection>
<MenuItem row>
<Grid item xs={10}>
<Typography variant="button" color="textSecondary" component="div">
Show Best Path
</Typography>
</Grid>
<Grid item xs={2}>
<Switch
checked={showBestPath}
onChange={onShowBestPathChange}
color="secondary"
disabled={definingPoints || fullSpeed}
id="show-best-path"
/>
</Grid>
<Grid item xs={10}>
<Typography variant="button" color="textSecondary" component="div">
Show Evaluated Paths
</Typography>
</Grid>
<Grid item xs={2}>
<Switch
checked={evaluatingDetailLevel > 0}
onChange={onEvaluatingDetailLevelChange(1, 0)}
color="secondary"
disabled={definingPoints || fullSpeed}
id="show-evaluating-paths"
/>
</Grid>
{maxEvaluatingDetailLevel > 1 && (
<>
<Grid item xs={10}>
<Typography
variant="button"
color="textSecondary"
component="div"
>
Show Evaluated Steps
</Typography>
</Grid>
<Grid item xs={2}>
<Switch
checked={evaluatingDetailLevel > 1}
onChange={onEvaluatingDetailLevelChange(2, 1)}
color="secondary"
disabled={definingPoints || fullSpeed}
id="show-evaluating-steps"
/>
</Grid>
</>
)}
</MenuItem>
</MenuSection>
</>
);
};

View file

@ -0,0 +1,22 @@
import React from "react";
import { Grid, Typography } from "@material-ui/core";
import { MenuItem } from "./MenuItem";
import { MenuSection } from "./MenuSection";
import { ThemeToggle } from "./ThemeToggle";
export const OtherControls = props => {
return (
<MenuSection>
<MenuItem row>
<Grid item xs={10}>
<Typography variant="button" color="textSecondary" component="div">
Dark Mode
</Typography>
</Grid>
<Grid item xs={2}>
<ThemeToggle />
</Grid>
</MenuItem>
</MenuSection>
);
};

27
src/components/SEO.jsx Normal file
View file

@ -0,0 +1,27 @@
import React from "react";
import Helmet from "react-helmet";
const description =
"Interactive solver for the traveling salesman problem to visualize different algorithms. Includes various Heuristic and Exhaustive algorithms.";
export const SEO = ({ subtitle }) => {
return (
<Helmet
title={`${subtitle}${
subtitle ? " | " : ""
}Traveling Salesman Problem Visualizer`}
htmlAttributes={{ lang: "en" }}
>
<meta name="description" content={description} />
<meta
property="og:title"
content={`Traveling Salesman Problem Visualizer`}
/>
<meta property="og:url" content="https://tspvis.com" />
<meta property="og:description" content={description} />
<meta property="og:image" content="https://i.imgur.com/u4ibcsC.jpg" />
<meta property="og:type" content="website" />
</Helmet>
);
};

View file

@ -0,0 +1,15 @@
import React from "react";
import { Switch } from "@material-ui/core";
import { useThemeContext } from "../context";
export const ThemeToggle = () => {
const { colorMode, toggleColorMode } = useThemeContext();
return (
<Switch
checked={colorMode === "dark"}
onChange={toggleColorMode}
color="secondary"
/>
);
};

15
src/components/index.js Normal file
View file

@ -0,0 +1,15 @@
export * from "./AlgorithmModals";
export * from "./InformationModal";
export * from "./IntroductionModal";
export * from "./Layout";
export * from "./MapPlot";
export * from "./Menu";
export * from "./MenuHeader";
export * from "./MenuItem";
export * from "./MenuMetrics";
export * from "./MenuPointControls";
export * from "./MenuSection";
export * from "./MenuSolverControls";
export * from "../context/PreSetTheme";
export * from "./SEO";
export * from "./ThemeToggle";

19
src/constants.js Normal file
View file

@ -0,0 +1,19 @@
// orangish
export const START_POINT_COLOR = [255, 87, 34];
// blueish
export const POINT_COLOR = [41, 121, 255];
// greenish
export const BEST_PATH_COLOR = [76, 175, 80];
// orangish
export const EVALUATING_PATH_COLOR = [255, 87, 34, 225];
// reddish
export const EVALUATING_ERROR_COLOR = [255, 25, 25, 240];
// greyish
export const EVALUATING_SEGMENT_COLOR = [180, 180, 180, 240];
export const COLOR_MODE_KEY = "color-mode";

View file

@ -0,0 +1,99 @@
---
type: exhaustive
order: 3
solverKey: branchAndBoundOnCost
friendlyName: Branch and Bound (Cost)
defaults:
evaluatingDetailLevel: 2
maxEvaluatingDetailLevel: 2
---
# Branch and Bound on Cost
This is a recursive algorithm, similar to depth first search, that is guaranteed to find the optimal solution.
The candidate solution space is generated by systematically traversing possible paths, and discarding large subsets of fruitless candidates by comparing the current solution to an upper and lower bound. In this case, the upper bound is the best path found so far.
While evaluating paths, if at any point the current solution is already more expensive (longer) than the best complete path discovered, there is no point continuing.
For example, imagine:
1. A -> B -> C -> D -> E -> A was already found with a cost of 100.
2. We are evaluating A -> C -> E, which has a cost of 110. There is **no point** evaluating the remaining solutions.
3. Instead of continuing to evaluate all of the child solutions from here, we can go down a different path, eliminating candidates not worth evaluating:
- `A -> C -> E -> D -> B -> A`
- `A -> C -> E -> B -> D -> A`
Implementation is very similar to depth first search, with the exception that we cut paths that are already longer than the current best.
## Implementation
```javascript
const branchAndBoundOnCost = async (
points,
path = [],
visited = null,
overallBest = Infinity
) => {
if (visited === null) {
// initial call
path = [points.shift()];
points = new Set(points);
visited = new Set();
}
// figure out which points are left
const available = setDifference(points, visited);
// calculate the cost, from here, to go home
const backToStart = [...path, path[0]];
const cost = pathCost(backToStart);
if (cost > overallBest) {
// we may not be done, but have already traveled further than the best path
// no reason to continue
return [null, null];
}
// still cheaper than the best, keep going deeper, and deeper, and deeper...
if (available.size === 0) {
// at the end of the path, return where we're at
return [cost, backToStart];
}
let [bestCost, bestPath] = [null, null];
// for every point yet to be visited along this path
for (const p of available) {
// go to that point
visited.add(p);
path.push(p);
// RECURSE - go through all the possible points from that point
const [curCost, curPath] = await branchAndBoundOnCost(
points,
path,
visited,
overallBest
);
// if that path is better and complete, keep it
if (curCost && (!bestCost || curCost < bestCost)) {
[bestCost, bestPath] = [curCost, curPath];
if (!overallBest || bestCost < overallBest) {
// found a new best complete path
overallBest = bestCost;
self.setBestPath(bestPath, bestCost);
}
}
// go back up and make that point available again
visited.delete(p);
path.pop();
}
return [bestCost, bestPath];
};
```

View file

@ -0,0 +1,56 @@
---
type: exhaustive
order: 4
solverKey: branchAndBoundOnCostAndCross
friendlyName: Branch and Bound (Cost, Crossings)
defaults:
evaluatingDetailLevel: 2
maxEvaluatingDetailLevel: 2
---
# Branch and Bound (Cost, Intersections)
This is the same as branch and bound on cost, with an additional heuristic added to further minimize the search space.
While traversing paths, if at any point the path intersects (crosses over) itself, than backtrack and try the next way. It's been proven that an optimal path will never contain crossings.
Implementation is almost identical to branch and bound on cost only, with the added heuristic below:
## Implementation
```javascript
const counterClockWise = (p, q, r) => {
return (q[0] - p[0]) * (r[1] - q[1]) <
(q[1] - p[1]) * (r[0] - q[0])
}
const intersects = (a, b, c, d) => {
return counterClockWise(a, c, d) !== counterClockWise(b, c, d) &&
counterClockWise(a, b, c) !== counterClockWise(a, b, d)
}
const branchAndBoundOnCostAndCross = async (...) => {
//
// .....
//
if (path.length > 3) {
// if this newly added edge crosses over the existing path,
// don't continue. It's been proven that an optimal path will
// not cross itself.
const newSegment = [
path[path.length-2], path[path.length-1]
]
for (let i=1; i<path.length-2; i++) {
if (intersects(path[i], path[i-1], ...newSegment)) {
return [null, null]
}
}
}
//
// .....
//
}
```

View file

@ -0,0 +1,87 @@
---
type: exhaustive
order: 1
solverKey: depthFirstSearch
friendlyName: Depth First Search (Brute Force)
defaults:
evaluatingDetailLevel: 2
maxEvaluatingDetailLevel: 2
---
# Depth First Search (Brute Force)
This is an exhaustive, brute-force algorithm. It is guaranteed to find the best possible path, however depending on the number of points in the traveling salesman problem it is likely impractical. For example,
- With 10 points there are 181,400 paths to evaluate.
- With 11 points, there are 1,814,000.
- With 12 points there are 19,960,000.
- With 20 points there are 60,820,000,000,000,000, give or take.
- With 25 points there are 310,200,000,000,000,000,000,000, give or take.
This is factorial growth, and it quickly makes the TSP impractical to brute force. That is why heuristics exist to give a good approximation of the best path, but it is very difficult to determine without a doubt what the best path is for a reasonably sized traveling salesman problem.
This is a recursive, depth-first-search algorithm, as follows:
1. From the starting point
2. For all other points not visited
3. If there are no points left return the current cost/path
4. Else, go to every remaining point and
:
1. Mark that point as visited
2. "**recurse**" through those paths (go back to 1. )
## Implementation
```javascript
const dfs = async (points, path = [], visited = null, overallBest = null) => {
if (visited === null) {
// initial call
path = [points.shift()];
points = new Set(points);
visited = new Set();
}
// figure out what points are left from this point
const available = setDifference(points, visited);
if (available.size === 0) {
// this must be a complete path
const backToStart = [...path, path[0]];
// calculate the cost of this path
const cost = pathCost(backToStart);
// return both the cost and the path where we're at
return [cost, backToStart];
}
let [bestCost, bestPath] = [null, null];
// for every point yet to be visited along this path
for (const p of available) {
// go to that point
visited.add(p);
path.push(p);
// RECURSE - go through all the possible points from that point
const [curCost, curPath] = await dfs(points, path, visited, overallBest);
// if that path is better, keep it
if (bestCost === null || curCost < bestCost) {
[bestCost, bestPath] = [curCost, curPath];
if (overallBest === null || bestCost < overallBest) {
// found a new best complete path
overallBest = bestCost;
}
}
// go back up and make that point available again
visited.delete(p);
path.pop();
}
return [bestCost, bestPath];
};
```

View file

@ -0,0 +1,53 @@
---
type: exhaustive
order: 2
solverKey: random
friendlyName: Random
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Random
This is an impractical, albeit exhaustive algorithm. It is here only for demonstration purposes, but will not find a reasonable path for traveling salesman problems above 7 or 8 points.
I consider it exhaustive because if it runs for infinity, eventually it will encounter every possible path.
1. From the starting path
2. Randomly shuffle the path
3. If it's better, keep it
4. If not, ditch it and keep going
## Implementation
```javascript
const random = async points => {
let best = Infinity;
while (true) {
// save off the starting point
const start = points.shift();
// sort the remaining points
const path = points.sort(() => Math.random() - 0.5);
// put the starting point back
path.unshift(start);
// return to the starting point
path.push(start);
// calculate the new cost
const cost = pathCost(path);
if (cost < best) {
// we found a better path
best = cost;
}
// get rid of starting point at the end
path.pop();
}
};
```

View file

@ -0,0 +1,61 @@
---
type: heuristic-construction
order: 2
solverKey: arbitraryInsertion
friendlyName: Arbitrary Insertion
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Arbitrary Insertion
This is a heuristic construction algorithm. It select a random point, and then figures out where the best place to put it will be.
1. From the starting point
2. First, go to the closest point
3. Choose a random point to go to
4. Find the cheapest place to add it in the path
5. Chosen point is no longer an "available point"
6. Continue from #3 until there are no available points, and then return to the start.
## Implementation
```javascript
const arbitraryInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
// randomly sort points - this is the order they will be added
// to the path
points.sort(() => Math.random() - 0.5);
while (points.length > 0) {
//
// SELECTION - choose a next point randomly
//
const nextPoint = points.pop();
//
// INSERTION -find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
}
// return to start after visiting all other points
path.push(path[0]);
};
```

View file

@ -0,0 +1,111 @@
---
type: heuristic-construction
order: 5
solverKey: convexHull
friendlyName: Convex Hull
defaults:
evaluatingDetailLevel: 2
maxEvaluatingDetailLevel: 2
---
# Convex Hull
This is a heuristic construction algorithm. It starts by building the [convex hull](https://en.wikipedia.org/wiki/Convex_hull), and adding interior points from there. This implmentation uses another heuristic for insertion based on the ratio of the cost of adding the new point to the overall length of the segment, however any insertion algorithm could be applied after building the hull.
There are a number of algorithms to determine the convex hull. This implementation uses the [gift wrapping algorithm](https://en.wikipedia.org/wiki/Gift_wrapping_algorithm).
In essence, the steps are:
1. Determine the leftmost point
2. Continually add the most counterclockwise point until the convex hull is formed
3. For each remaining point p, find the segment i => j in the hull that minimizes cost(i -> p) + cost(p -> j) - cost(i -> j)
4. Of those, choose p that minimizes cost(i -> p -> j) / cost(i -> j)
5. Add p to the path between i and j
6. Repeat from #3 until there are no remaining points
## Implementation
```javascript
const convexHull = async points => {
const sp = points[0];
// Find the "left most point"
let leftmost = points[0];
for (const p of points) {
if (p[1] < leftmost[1]) {
leftmost = p;
}
}
const path = [leftmost];
while (true) {
const curPoint = path[path.length - 1];
let [selectedIdx, selectedPoint] = [0, null];
// find the "most counterclockwise" point
for (let [idx, p] of points.entries()) {
if (!selectedPoint || orientation(curPoint, p, selectedPoint) === 2) {
// this point is counterclockwise with respect to the current hull
// and selected point (e.g. more counterclockwise)
[selectedIdx, selectedPoint] = [idx, p];
}
}
// adding this to the hull so it's no longer available
points.splice(selectedIdx, 1);
// back to the furthest left point, formed a cycle, break
if (selectedPoint === leftmost) {
break;
}
// add to hull
path.push(selectedPoint);
}
while (points.length > 0) {
let [bestRatio, bestPointIdx, insertIdx] = [Infinity, null, 0];
for (let [freeIdx, freePoint] of points.entries()) {
// for every free point, find the point in the current path
// that minimizes the cost of adding the point minus the cost of
// the original segment
let [bestCost, bestIdx] = [Infinity, 0];
for (let [pathIdx, pathPoint] of path.entries()) {
const nextPathPoint = path[(pathIdx + 1) % path.length];
// the new cost minus the old cost
const evalCost =
pathCost([pathPoint, freePoint, nextPathPoint]) -
pathCost([pathPoint, nextPathPoint]);
if (evalCost < bestCost) {
[bestCost, bestIdx] = [evalCost, pathIdx];
}
}
// figure out how "much" more expensive this is with respect to the
// overall length of the segment
const nextPoint = path[(bestIdx + 1) % path.length];
const prevCost = pathCost([path[bestIdx], nextPoint]);
const newCost = pathCost([path[bestIdx], freePoint, nextPoint]);
const ratio = newCost / prevCost;
if (ratio < bestRatio) {
[bestRatio, bestPointIdx, insertIdx] = [ratio, freeIdx, bestIdx + 1];
}
}
const [nextPoint] = points.splice(bestPointIdx, 1);
path.splice(insertIdx, 0, nextPoint);
}
// rotate the array so that starting point is back first
const startIdx = path.findIndex(p => p === sp);
path.unshift(...path.splice(startIdx, path.length));
// go back home
path.push(sp);
};
```

View file

@ -0,0 +1,73 @@
---
type: heuristic-construction
order: 4
solverKey: furthestInsertion
friendlyName: Furthest Insertion
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Furthest Insertion
This is a heuristic construction algorithm. It selects the furthest point from the path, and then figures out where the best place to put it will be.
1. From the starting point
2. First, go to the closest point
3. Choose the point that is furthest from any of the points on the path
4. Find the cheapest place to add it in the path
5. Chosen point is no longer an "available point"
6. Continue from #3 until there are no available points, and then return to the start.
## Implementation
```javascript
const furthestInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point first
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
while (points.length > 0) {
//
// SELECTION - furthest point from the path
//
let [selectedDistance, selectedIdx] = [0, null];
for (const [freePointIdx, freePoint] of points.entries()) {
// find the minimum distance to the path for freePoint
let [bestCostToPath, costToPathIdx] = [Infinity, null];
for (const pathPoint of path) {
const dist = distance(freePoint, pathPoint);
if (dist < bestCostToPath) {
[bestCostToPath, costToPathIdx] = [dist, freePointIdx];
}
}
// if this point is further from the path than the currently selected
if (bestCostToPath > selectedDistance) {
[selectedDistance, selectedIdx] = [bestCostToPath, costToPathIdx];
}
}
const [nextPoint] = points.splice(selectedIdx, 1);
//
// INSERTION - find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
}
// return to start after visiting all other points
path.push(path[0]);
};
```

View file

@ -0,0 +1,68 @@
---
type: heuristic-construction
order: 3
solverKey: nearestInsertion
friendlyName: Nearest Insertion
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Furthest Insertion
This is a heuristic construction algorithm. It selects the closest point to the path, and then figures out where the best place to put it will be.
1. From the starting point
2. First, go to the closest point
3. Choose the point that is **nearest** to the current path
4. Find the cheapest place to add it in the path
5. Chosen point is no longer an "available point"
6. Continue from #3 until there are no available points, and then return to the start.
## Implementation
```javascript
const nearestInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point first
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
while (points.length > 0) {
//
// SELECTION - nearest point to the path
//
let [selectedDistance, selectedIdx] = [Infinity, null];
for (const [freePointIdx, freePoint] of points.entries()) {
for (const pathPoint of path) {
const dist = distance(freePoint, pathPoint);
if (dist < selectedDistance) {
[selectedDistance, selectedIdx] = [dist, freePointIdx];
}
}
}
// get the next point to add
const [nextPoint] = points.splice(selectedIdx, 1);
//
// INSERTION - find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
}
// return to start after visiting all other points
path.push(path[0]);
};
```

View file

@ -0,0 +1,43 @@
---
type: heuristic-construction
order: 1
solverKey: nearestNeighbor
friendlyName: Nearest Neighbor
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Nearest Neighbor
This is a heuristic, greedy algorithm also known as nearest neighbor. It continually chooses the best looking option from the current state.
1. From the starting point
2. sort the remaining available points based on cost (distance)
3. Choose the closest point and go there
4. Chosen point is no longer an "available point"
5. Continue this way until there are no available points, and then return to the start.
## Implementation
```javascript
const nearestNeighbor = async points => {
const path = [points.shift()];
while (points.length > 0) {
// sort remaining points in place by their
// distance from the last point in the current path
points.sort(
(a, b) =>
distance(path[path.length - 1], b) - distance(path[path.length - 1], a)
);
// go to the closest remaining point
path.push(points.pop());
}
// return to start after visiting all other points
path.push(path[0]);
const cost = pathCost(path);
};
```

View file

@ -0,0 +1,105 @@
---
type: heuristic-construction
order: 6
solverKey: simulatedAnnealing
friendlyName: Simulated Annealing
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Simulated Annealing
Simulated annealing (SA) is a probabilistic technique for approximating the global optimum of a given function. Specifically, it is a metaheuristic to approximate global optimization in a large search space for an optimization problem.
For problems where finding an approximate global optimum is more important than finding a precise local optimum in a fixed amount of time, simulated annealing may be preferable to exact algorithms
## Implementation
```javascript
const simulatedAnnealing = async points => {
const sp = points[0];
const path = points;
const tempCoeff =
path.length < 10
? 1 - 1e-4
: path.length < 15
? 1 - 1e-5
: path.length < 25
? 1 - 1e-6
: 1 - 5e-7;
const deltaDistance = (aIdx, bIdx) => {
const aPrev = (aIdx - 1 + path.length) % path.length;
const aNext = (aIdx + 1 + path.length) % path.length;
const bPrev = (bIdx - 1 + path.length) % path.length;
const bNext = (bIdx + 1 + path.length) % path.length;
let diff =
distance(path[bPrev], path[aIdx]) +
distance(path[aIdx], path[bNext]) +
distance(path[aPrev], path[bIdx]) +
distance(path[bIdx], path[aNext]) -
distance(path[aPrev], path[aIdx]) -
distance(path[aIdx], path[aNext]) -
distance(path[bPrev], path[bIdx]) -
distance(path[bIdx], path[bNext]);
if (bPrev === aIdx || bNext === aIdx) {
diff += 2 * distance(path[aIdx], path[bIdx]);
}
return diff;
};
const changePath = temperature => {
// 2 random points
const a = 1 + Math.floor(Math.random() * (path.length - 1));
const b = 1 + Math.floor(Math.random() * (path.length - 1));
const delta = deltaDistance(a, b);
if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) {
// swap points
[path[a], path[b]] = [path[b], path[a]];
}
};
const initialTemp = 100 * distance(path[0], path[1]);
let i = 0;
for (
let temperature = initialTemp;
temperature > 1e-6;
temperature *= tempCoeff
) {
changePath(temperature);
if (i % 10000 == 0) {
self.setEvaluatingPaths(() => ({
paths: [{ path, color: EVALUATING_PATH_COLOR }],
cost: pathCost(path)
}));
await self.sleep();
}
if (i % 100000 == 0) {
path.push(sp);
self.setBestPath(path, pathCost(path));
path.pop();
}
i++;
}
// rotate the array so that starting point is back first
rotateToStartingPoint(path, sp);
// go back home
path.push(sp);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
self.setBestPath(path, cost);
};
makeSolver(simulatedAnnealing);
```

View file

@ -0,0 +1,60 @@
---
type: heuristic-improvement
order: 1
solverKey: twoOptInversion
friendlyName: Two Opt Inversion
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Two-Opt inversion
This algorithm is also known as 2-opt, 2-opt mutation, and cross-aversion. The general goal is to find places where the path crosses over itself, and then "undo" that crossing. It repeats until there are no crossings. A characteristic of this algorithm is that afterwards the path is guaranteed to have no crossings.
1. While a better path has not been found.
2. For each pair of points:
3. Reverse the path between the selected points.
4. If the new path is cheaper (shorter), keep it and continue searching. Remember that we found a better path.
5. If not, revert the path and continue searching.
## Implementation
```javascript
const twoOptInversion = async path => {
path.push(path[0]);
let best = pathCost(path);
let swapped = true;
while (swapped) {
swapped = false;
for (let pt1 = 1; pt1 < path.length - 1; pt1++) {
for (let pt2 = pt1 + 1; pt2 < path.length - 1; pt2++) {
// section of the path to reverse
const section = path.slice(pt1, pt2 + 1);
// reverse section in place
section.reverse();
// replace section of path with reversed section in place
path.splice(pt1, pt2 + 1 - pt1, ...section);
// calculate new cost
const newPath = path;
const cost = pathCost(newPath);
if (cost < best) {
// found a better path after the swap, keep it
swapped = true;
best = cost;
self.setBestPath(newPath, best);
} else {
// un-reverse the section
section.reverse();
path.splice(pt1, pt2 + 1 - pt1, ...section);
}
}
}
}
};
```

View file

@ -0,0 +1,57 @@
---
type: heuristic-improvement
order: 2
solverKey: twoOptReciprocalExchange
friendlyName: Two Opt Reciprocal Exchange
defaults:
evaluatingDetailLevel: 1
maxEvaluatingDetailLevel: 1
---
# Two-Opt Reciprocal Exchange
This algorithm is similar to the 2-opt mutation or inversion algorithm, although generally will find a less optimal path. However, the computational cost of calculating new solutions is less intensive.
The big difference with 2-opt mutation is not reversing the path between the 2 points. This algorithm is **not** always going to find a path that doesn't cross itself.
It could be worthwhile to try this algorithm prior to 2-opt inversion because of the cheaper cost of calculation, but probably not.
1. While a better path has not been found.
2. For each pair of points:
3. Swap the points in the path. That is, go to point B before point A, continue along the same path, and go to point A where point B was.
4. If the new path is cheaper (shorter), keep it and continue searching. Remember that we found a better path.
5. If not, revert the path and continue searching.
## Implementation
```javascript
const twoOptReciprocalExchange = async path => {
path.push(path[0]);
let best = pathCost(path);
let swapped = true;
self.setBestPath(path, best);
while (swapped) {
swapped = false;
for (let pt1 = 1; pt1 < path.length - 1; pt1++) {
for (let pt2 = pt1 + 1; pt2 < path.length - 1; pt2++) {
// swap current pair of points
[path[pt1], path[pt2]] = [path[pt2], path[pt1]];
// calculate new cost
const cost = pathCost(path);
if (cost < best) {
// found a better path after the swap, keep it
swapped = true;
best = cost;
} else {
// swap back - this one's worse
[path[pt1], path[pt2]] = [path[pt2], path[pt1]];
}
}
}
}
};
```

View file

@ -0,0 +1,54 @@
---
type: introduction
---
# Traveling Salesman Problem
The traveling salesman problem (TSP) asks the question, "Given a list of cities and the distances between each pair of cities, what is the shortest possible route that visits each city and returns to the origin city?".
### This project
- The goal of this site is to be an **educational** resource to help visualize, learn, and develop different algorithms for the traveling salesman problem in a way that's easily accessible
- As you apply different algorithms, the current best path is saved and used as input to whatever you run next. (e.g. shortest path first -> branch and bound). The order in which you apply different algorithms to the problem is sometimes referred to the meta-heuristic strategy.
### Heuristic algorithms
Heuristic algorithms attempt to find a good approximation of the optimal path within a more _reasonable_ amount of time.
**Construction** - Build a path (e.g. shortest path)
- Shortest Path
- Arbitrary Insertion
- Furthest Insertion
- Nearest Insertion
- Convex Hull Insertion\*
- Simulated Annealing\*
**Improvement** - Attempt to take an existing constructed path and improve on it
- 2-Opt Inversion
- 2-Opt Reciprcal Exchange\*
### Exhaustive algorithms
Exhaustive algorithms will always find the best possible solution by evaluating every possible path. These algorithms are typically significantly more expensive then the heuristic algorithms discussed next. The exhaustive algorithms implemented so far include:
- Random Paths
- Depth First Search (Brute Force)
- Branch and Bound (Cost)
- Branch and Bound (Cost, Intersections)\*
## Dependencies
These are the main tools used to build this site:
- [gatsbyjs](https://www.gatsbyjs.org)
- [reactjs](https://reactjs.org)
- [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)
- [material-ui](https://material-ui.com/)
- [deck.gl](https://deck.gl/#/)
- [mapbox](https://www.mapbox.com/)
## Contributing
Pull requests are always welcome! Also, feel free to raise any ideas, suggestions, or bugs as an issue.

View file

@ -0,0 +1,23 @@
import React from "react";
export const PreSetTheme = () => (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
function getInitialMode() {
const stored = window.localStorage.getItem('color-mode');
if (stored && (stored === "dark" || stored === "light")) {
return stored;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
return mql.matches ? 'dark' : 'light';
}
const mode = getInitialMode();
localStorage.setItem('color-mode', mode);
})();
`
}}
/>
);

View file

@ -0,0 +1,51 @@
import React from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
import { ThemeProvider as MUIThemeProvider } from "@material-ui/styles";
import { CssBaseline, createTheme } from "@material-ui/core";
import blue from "@material-ui/core/colors/blue";
import orange from "@material-ui/core/colors/orange";
import { COLOR_MODE_KEY } from "../constants";
import { usePersistentState } from "../hooks";
export const ThemeContext = createContext();
export const ThemeContextProvider = props => {
const { children } = props;
const [colorMode, setColorMode] = usePersistentState(COLOR_MODE_KEY, "dark");
const muiTheme = useMemo(
() =>
createTheme({
palette: {
primary: blue,
secondary: orange,
type: colorMode
}
}),
[colorMode]
);
const toggleColorMode = useCallback(() => {
setColorMode(current => (current === "dark" ? "light" : "dark"));
}, []);
return (
<MUIThemeProvider theme={muiTheme}>
<CssBaseline />
<ThemeContext.Provider
value={{
colorMode,
setColorMode,
toggleColorMode,
muiTheme
}}
>
{children}
</ThemeContext.Provider>
</MUIThemeProvider>
);
};
export const useThemeContext = () => useContext(ThemeContext);

2
src/context/index.js Normal file
View file

@ -0,0 +1,2 @@
export * from "./PreSetTheme";
export * from "./ThemeContext";

6
src/hooks/index.js Normal file
View file

@ -0,0 +1,6 @@
export * from "./useAlgorithmInfo";
export * from "./useIntroductionInfo";
export * from "./useIsFirstLoad";
export * from "./usePersistentState";
export * from "./useSolverWorker";
export * from "./useUpdateEffect";

View file

@ -0,0 +1,45 @@
import { useStaticQuery, graphql } from "gatsby";
export const useAlgorithmInfo = () => {
const {
allMarkdownRemark: { edges: algorithms }
} = useStaticQuery(graphql`
query AlgorithmModalsQuery {
allMarkdownRemark(
filter: {
frontmatter: {
type: {
in: [
"exhaustive"
"heuristic-construction"
"heuristic-improvement"
]
}
}
}
sort: { fields: frontmatter___order }
) {
edges {
node {
frontmatter {
order
friendlyName
solverKey
type
defaults {
evaluatingDetailLevel
maxEvaluatingDetailLevel
}
}
html
}
}
}
}
`);
return algorithms.map(alg => ({
...alg.node.frontmatter,
html: alg.node.html
}));
};

View file

@ -0,0 +1,21 @@
import { useStaticQuery, graphql } from "gatsby";
export const useIntroductionInfo = () => {
const {
allMarkdownRemark: { edges: introductions }
} = useStaticQuery(graphql`
query IntroductionModalQuery {
allMarkdownRemark(
filter: { frontmatter: { type: { eq: "introduction" } } }
) {
edges {
node {
html
}
}
}
}
`);
return introductions[0].node.html;
};

View file

@ -0,0 +1,12 @@
export const useIsFirstLoad = (keyName = "isFirstVisit") => {
if (!window.localStorage) {
return false;
}
if (!localStorage[keyName]) {
localStorage.setItem(keyName, true);
return true;
}
return false;
};

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export const usePersistentState = (key, defaultValue = undefined) => {
const [value, setValue] = useState(() => {
const existing = typeof window !== "undefined" && window.localStorage?.getItem(key);
if (existing) {
return existing;
}
return defaultValue;
});
useEffect(() => {
localStorage.setItem(key, value);
}, [value, key]);
return [value, setValue];
};

View file

@ -0,0 +1,29 @@
import { useState, useEffect } from "react";
import solvers from "../solvers";
export const useSolverWorker = (onSolverMessage, algorithm) => {
const [solver, setSolver] = useState();
const resetSolver = () => {
if (solver) {
solver.terminate();
}
const worker = new solvers[algorithm]();
worker.onmessage = ({ data }) => onSolverMessage(data);
worker.onerror = console.error;
setSolver(worker);
};
useEffect(resetSolver, [algorithm, onSolverMessage]);
const postMessage = data => {
if (solver) {
solver.postMessage(data);
}
};
return {
postMessage,
terminate: resetSolver
};
};

View file

@ -0,0 +1,14 @@
// https://stackoverflow.com/questions/55075604/react-hooks-useeffect-only-on-update
import { useEffect, useRef } from "react";
export const useUpdateEffect = (effect, dependencies = []) => {
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
effect();
}
}, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};

BIN
src/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

103
src/pages/index.js Normal file
View file

@ -0,0 +1,103 @@
import React, {
useRef,
useEffect,
useCallback,
useState,
useMemo
} from "react";
import { useSelector, useDispatch } from "react-redux";
import {
AlgorithmModals,
IntroductionModal,
Layout,
MapPlot,
Menu,
SEO,
ThemeToggle
} from "../components";
import { useSolverWorker, useAlgorithmInfo } from "../hooks";
import * as selectors from "../store/selectors";
import * as actions from "../store/actions";
const IndexPage = () => {
const mapRef = useRef(null);
const dispatch = useDispatch();
const algorithm = useSelector(selectors.selectAlgorithm);
const algorithmInfo = useAlgorithmInfo();
const delay = useSelector(selectors.selectDelay);
const evaluatingDetailLevel = useSelector(
selectors.selectEvaluatingDetailLevel
);
const points = useSelector(selectors.selectPoints);
const pointCount = useSelector(selectors.selectPointCount);
const definingPoints = useSelector(selectors.selectDefiningPoints);
const solver = useSolverWorker(dispatch, algorithm);
const onRandomizePoints = useCallback(() => {
if (!definingPoints) {
const bounds = mapRef.current.getBounds();
dispatch(actions.randomizePoints(bounds, pointCount));
}
}, [mapRef, dispatch, pointCount, definingPoints]);
const start = useCallback(() => {
dispatch(actions.startSolving(points, delay, evaluatingDetailLevel));
solver.postMessage(
actions.startSolvingAction(points, delay, evaluatingDetailLevel)
);
}, [solver, dispatch, delay, points, evaluatingDetailLevel]);
const fullSpeed = useCallback(() => {
dispatch(actions.goFullSpeed());
solver.postMessage(actions.goFullSpeed());
}, [solver, dispatch]);
const pause = useCallback(() => {
dispatch(actions.pause());
solver.postMessage(actions.pause());
}, [solver, dispatch]);
const unpause = useCallback(() => {
dispatch(actions.unpause());
solver.postMessage(actions.unpause());
}, [solver, dispatch]);
const stop = useCallback(() => {
dispatch(actions.stopSolving());
solver.terminate();
}, [solver, dispatch]);
useEffect(() => {
solver.postMessage(actions.setDelay(delay));
}, [delay, solver]);
useEffect(() => {
solver.postMessage(actions.setEvaluatingDetailLevel(evaluatingDetailLevel));
}, [evaluatingDetailLevel, solver]);
const algTitle = useMemo(() => {
const alg = algorithmInfo.find(alg => alg.solverKey === algorithm);
return alg.friendlyName;
}, [algorithm, algorithmInfo]);
return (
<Layout>
<SEO subtitle={algTitle} />
<IntroductionModal />
<AlgorithmModals />
<Menu
onStart={start}
onPause={pause}
onUnPause={unpause}
onFullSpeed={fullSpeed}
onStop={stop}
onRandomizePoints={onRandomizePoints}
/>
<MapPlot ref={mapRef}></MapPlot>
</Layout>
);
};
export default IndexPage;

56
src/solvers/cost.js Normal file
View file

@ -0,0 +1,56 @@
// haversine great circle distance
export const distance = (pt1, pt2) => {
const [lng1, lat1] = pt1;
const [lng2, lat2] = pt2;
if (lat1 === lat2 && lng1 === lng2) {
return 0;
}
var radlat1 = (Math.PI * lat1) / 180;
var radlat2 = (Math.PI * lat2) / 180;
var theta = lng1 - lng2;
var radtheta = (Math.PI * theta) / 180;
var dist =
Math.sin(radlat1) * Math.sin(radlat2) +
Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta);
if (dist > 1) {
dist = 1;
}
dist = Math.acos(dist);
dist = (dist * 180) / Math.PI;
return dist * 60 * 1.1515 * 1.609344;
};
export const pathCost = path => {
return path
.slice(0, -1)
.map((point, idx) => distance(point, path[idx + 1]))
.reduce((a, b) => a + b, 0);
};
export const counterClockWise = (p, q, r) => {
return (q[0] - p[0]) * (r[1] - q[1]) < (q[1] - p[1]) * (r[0] - q[0]);
};
export const intersects = (a, b, c, d) => {
return (
counterClockWise(a, c, d) !== counterClockWise(b, c, d) &&
counterClockWise(a, b, c) !== counterClockWise(a, b, d)
);
};
export const setDifference = (setA, setB) => {
const ret = new Set(setA);
setB.forEach(p => {
ret.delete(p);
});
return ret;
};
export const rotateToStartingPoint = (path, startingPoint) => {
const startIdx = path.findIndex(p => p === startingPoint);
path.unshift(...path.splice(startIdx, path.length));
};

View file

@ -0,0 +1,131 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, setDifference } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_ERROR_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const branchAndBoundOnCost = async (
points,
path = [],
visited = null,
overallBest = Infinity
) => {
if (visited === null) {
// initial call
path = [points.shift()];
points = new Set(points);
visited = new Set();
}
// figure out which points are left
const available = setDifference(points, visited);
// calculate the cost, from here, to go home
const backToStart = [...path, path[0]];
const cost = pathCost(backToStart);
if (cost > overallBest) {
// we may not be done, but have already traveled further than the best path
// no reason to continue
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_ERROR_COLOR
}
],
cost
}),
2
);
await self.sleep();
return [null, null];
}
// still cheaper than the best, keep going deeper, and deeper, and deeper...
else {
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_PATH_COLOR
}
],
cost
}),
2
);
}
await self.sleep();
if (available.size === 0) {
// at the end of the path, return where we're at
self.setEvaluatingPath(() => ({
path: { path: backToStart, color: EVALUATING_SEGMENT_COLOR },
cost
}));
return [cost, backToStart];
}
let [bestCost, bestPath] = [null, null];
// for every point yet to be visited along this path
for (const p of available) {
// go to that point
visited.add(p);
path.push(p);
// RECURSE - go through all the possible points from that point
const [curCost, curPath] = await branchAndBoundOnCost(
points,
path,
visited,
overallBest
);
// if that path is better and complete, keep it
if (curCost && (!bestCost || curCost < bestCost)) {
[bestCost, bestPath] = [curCost, curPath];
if (!overallBest || bestCost < overallBest) {
// found a new best complete path
overallBest = bestCost;
self.setBestPath(bestPath, bestCost);
}
}
// go back up and make that point available again
visited.delete(p);
path.pop();
self.setEvaluatingPath(
() => ({
path: { path, color: EVALUATING_SEGMENT_COLOR }
}),
2
);
await self.sleep();
}
await self.sleep();
return [bestCost, bestPath];
};
makeSolver(branchAndBoundOnCost);

View file

@ -0,0 +1,159 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, setDifference, intersects } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_ERROR_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const branchAndBoundOnCostAndCross = async (
points,
path = [],
visited = null,
overallBest = Infinity
) => {
if (visited === null) {
// initial call
path = [points.shift()];
points = new Set(points);
visited = new Set();
}
// figure out which points are left
const available = setDifference(points, visited);
// calculate the cost, from here, to go home
const backToStart = [...path, path[0]];
const cost = pathCost(backToStart);
if (path.length > 3) {
// if this newly added edge crosses over the existing path,
// don't continue. It's been proven that an optimal path will
// not cross itself.
const newSegment = [path[path.length - 2], path[path.length - 1]];
for (let i = 1; i < path.length - 2; i++) {
if (intersects(path[i], path[i - 1], ...newSegment)) {
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_ERROR_COLOR
}
],
cost
}),
2
);
await self.sleep();
return [null, null];
}
}
}
if (cost > overallBest) {
// we may not be done, but have already traveled further than the best path
// no reason to continue
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_ERROR_COLOR
}
],
cost
}),
2
);
await self.sleep();
return [null, null];
}
// still cheaper than the best, keep going deeper, and deeper, and deeper...
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_PATH_COLOR
}
],
cost
}),
2
);
await self.sleep();
if (available.size === 0) {
// at the end of the path, return where we're at
self.setEvaluatingPath(() => ({
path: { path: backToStart, color: EVALUATING_SEGMENT_COLOR },
cost
}));
await self.sleep();
return [cost, backToStart];
}
let [bestCost, bestPath] = [null, null];
// for every point yet to be visited along this path
for (const p of available) {
// go to that point
visited.add(p);
path.push(p);
// RECURSE - go through all the possible points from that point
const [curCost, curPath] = await branchAndBoundOnCostAndCross(
points,
path,
visited,
overallBest
);
// if that path is better and complete, keep it
if (curCost && (!bestCost || curCost < bestCost)) {
[bestCost, bestPath] = [curCost, curPath];
if (!overallBest || bestCost < overallBest) {
// found a new best complete path
overallBest = bestCost;
self.setBestPath(bestPath, bestCost);
}
}
// go back up and make that point available again
visited.delete(p);
path.pop();
self.setEvaluatingPath(
() => ({
path: { path, color: EVALUATING_SEGMENT_COLOR }
}),
2
);
await self.sleep();
}
await self.sleep();
return [bestCost, bestPath];
};
makeSolver(branchAndBoundOnCostAndCross);

View file

@ -0,0 +1,103 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const setDifference = (setA, setB) => {
const ret = new Set(setA);
setB.forEach(p => {
ret.delete(p);
});
return ret;
};
const dfs = async (points, path = [], visited = null, overallBest = null) => {
if (visited === null) {
// initial call
path = [points.shift()];
points = new Set(points);
visited = new Set();
}
self.setEvaluatingPaths(
() => ({
paths: [
{
path: path.slice(0, path.length - 1),
color: EVALUATING_SEGMENT_COLOR
},
{
path: path.slice(path.length - 2, path.length + 1),
color: EVALUATING_PATH_COLOR
}
]
}),
2
);
await self.sleep();
// figure out what points are left from this point
const available = setDifference(points, visited);
if (available.size === 0) {
// this must be a complete path
const backToStart = [...path, path[0]];
// calculate the cost of this path
const cost = pathCost(backToStart);
self.setEvaluatingPath(
() => ({
path: { path: backToStart, color: EVALUATING_SEGMENT_COLOR }
}),
cost
);
await self.sleep();
// return both the cost and the path where we're at
return [cost, backToStart];
}
let [bestCost, bestPath] = [null, null];
// for every point yet to be visited along this path
for (const p of available) {
// go to that point
visited.add(p);
path.push(p);
// RECURSE - go through all the possible points from that point
const [curCost, curPath] = await dfs(points, path, visited, overallBest);
// if that path is better, keep it
if (bestCost === null || curCost < bestCost) {
[bestCost, bestPath] = [curCost, curPath];
if (overallBest === null || bestCost < overallBest) {
// found a new best complete path
overallBest = bestCost;
self.setBestPath(bestPath, bestCost);
}
}
// go back up and make that point available again
visited.delete(p);
path.pop();
self.setEvaluatingPath(
() => ({
path: { path, color: EVALUATING_SEGMENT_COLOR }
}),
2
);
await self.sleep();
}
return [bestCost, bestPath];
};
makeSolver(dfs);

View file

@ -0,0 +1,41 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost } from "../cost";
const random = async points => {
let best = Infinity;
while (true) {
// save off the starting point
const start = points.shift();
// sort the remaining points
const path = points.sort(() => Math.random() - 0.5);
// put the starting point back
path.unshift(start);
// return to the starting point
path.push(start);
// calculate the new cost
const cost = pathCost(path);
if (cost < best) {
// we found a better path
best = cost;
self.setBestPath(path, cost);
}
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
// get rid of starting point at the end
path.pop();
await self.sleep();
}
};
makeSolver(random);

View file

@ -0,0 +1,63 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, distance } from "../cost";
const arbitraryInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point first
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
// randomly sort points - this is the order they will be added
// to the path
points.sort(() => Math.random() - 0.5);
while (points.length > 0) {
//
// SELECTION - choose a next point randomly
//
const nextPoint = points.pop();
//
// INSERTION - find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
}
// return to start after visiting all other points
path.push(path[0]);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
await self.sleep();
self.setBestPath(path, cost);
};
makeSolver(arbitraryInsertion);

View file

@ -0,0 +1,125 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, counterClockWise, rotateToStartingPoint } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const convexHull = async points => {
const sp = points[0];
// Find the "left most point"
let leftmost = points[0];
for (const p of points) {
if (p[1] < leftmost[1]) {
leftmost = p;
}
}
const path = [leftmost];
while (true) {
const curPoint = path[path.length - 1];
let [selectedIdx, selectedPoint] = [0, null];
// find the "most counterclockwise" point
for (let [idx, p] of points.entries()) {
// eslint-disable-next-line
self.setEvaluatingPaths(
() => ({
paths: [
{
path: [...path, selectedPoint || curPoint],
color: EVALUATING_SEGMENT_COLOR
},
{ path: [curPoint, p], color: EVALUATING_PATH_COLOR }
]
}),
2
);
await self.sleep();
if (!selectedPoint || counterClockWise(curPoint, p, selectedPoint)) {
// this point is counterclockwise with respect to the current hull
// and selected point (e.g. more counterclockwise)
[selectedIdx, selectedPoint] = [idx, p];
}
}
// adding this to the hull so it's no longer available
points.splice(selectedIdx, 1);
// back to the furthest left point, formed a cycle, break
if (selectedPoint === leftmost) {
break;
}
// add to hull
path.push(selectedPoint);
}
self.setEvaluatingPaths(() => ({
paths: [{ path, color: EVALUATING_PATH_COLOR }],
cost: pathCost(path)
}));
await self.sleep();
while (points.length > 0) {
let [bestRatio, bestPointIdx, insertIdx] = [Infinity, null, 0];
for (let [freeIdx, freePoint] of points.entries()) {
// for every free point, find the point in the current path
// that minimizes the cost of adding the path minus the cost of
// the original segment
let [bestCost, bestIdx] = [Infinity, 0];
for (let [pathIdx, pathPoint] of path.entries()) {
const nextPathPoint = path[(pathIdx + 1) % path.length];
// the new cost minus the old cost
const evalCost =
pathCost([pathPoint, freePoint, nextPathPoint]) -
pathCost([pathPoint, nextPathPoint]);
if (evalCost < bestCost) {
[bestCost, bestIdx] = [evalCost, pathIdx];
}
}
// figure out how "much" more expensive this is with respect to the
// overall length of the segment
const nextPoint = path[(bestIdx + 1) % path.length];
const prevCost = pathCost([path[bestIdx], nextPoint]);
const newCost = pathCost([path[bestIdx], freePoint, nextPoint]);
const ratio = newCost / prevCost;
if (ratio < bestRatio) {
[bestRatio, bestPointIdx, insertIdx] = [ratio, freeIdx, bestIdx + 1];
}
}
const [nextPoint] = points.splice(bestPointIdx, 1);
path.splice(insertIdx, 0, nextPoint);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
}
// rotate the array so that starting point is back first
rotateToStartingPoint(path, sp);
// go back home
path.push(sp);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
self.setBestPath(path, cost);
};
makeSolver(convexHull);

View file

@ -0,0 +1,79 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, distance } from "../cost";
const furthestInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point first
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
while (points.length > 0) {
//
// SELECTION - furthest point from the path
//
let [selectedDistance, selectedIdx] = [0, null];
for (const [freePointIdx, freePoint] of points.entries()) {
// find the minimum distance to the path for freePoint
let [bestCostToPath, costToPathIdx] = [Infinity, null];
for (const pathPoint of path) {
const dist = distance(freePoint, pathPoint);
if (dist < bestCostToPath) {
[bestCostToPath, costToPathIdx] = [dist, freePointIdx];
}
}
// if this point is further from the path than the currently selected
if (bestCostToPath > selectedDistance) {
[selectedDistance, selectedIdx] = [bestCostToPath, costToPathIdx];
}
}
// get the next point to add
const [nextPoint] = points.splice(selectedIdx, 1);
//
// INSERTION - find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
}
// return to start after visiting all other points
path.push(path[0]);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
await self.sleep();
self.setBestPath(path, cost);
};
makeSolver(furthestInsertion);

View file

@ -0,0 +1,72 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, distance } from "../cost";
const nearestInsertion = async points => {
// from the starting point
const path = [points.shift()];
//
// INITIALIZATION - go to the nearest point first
//
points.sort((a, b) => distance(path[0], b) - distance(path[0], a));
path.push(points.pop());
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
while (points.length > 0) {
//
// SELECTION - nearest point to the path
//
let [selectedDistance, selectedIdx] = [Infinity, null];
for (const [freePointIdx, freePoint] of points.entries()) {
for (const pathPoint of path) {
const dist = distance(freePoint, pathPoint);
if (dist < selectedDistance) {
[selectedDistance, selectedIdx] = [dist, freePointIdx];
}
}
}
// get the next point to add
const [nextPoint] = points.splice(selectedIdx, 1);
//
// INSERTION - find the insertion spot that minimizes distance
//
let [bestCost, bestIdx] = [Infinity, null];
for (let i = 1; i < path.length; i++) {
const insertionCost = pathCost([path[i - 1], nextPoint, path[i]]);
if (insertionCost < bestCost) {
[bestCost, bestIdx] = [insertionCost, i];
}
}
path.splice(bestIdx, 0, nextPoint);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
}
// return to start after visiting all other points
path.push(path[0]);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
await self.sleep();
self.setBestPath(path, cost);
};
makeSolver(nearestInsertion);

View file

@ -0,0 +1,40 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, distance } from "../cost";
const nearestNeighbor = async points => {
const path = [points.shift()];
while (points.length > 0) {
// sort remaining points in place by their
// distance from the last point in the current path
points.sort(
(a, b) =>
distance(path[path.length - 1], b) - distance(path[path.length - 1], a)
);
// go to the closest remaining point
path.push(points.pop());
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost: pathCost(path)
}));
await self.sleep();
}
// return to start after visiting all other points
path.push(path[0]);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
await self.sleep();
self.setBestPath(path, cost);
};
makeSolver(nearestNeighbor);

View file

@ -0,0 +1,90 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost, distance, rotateToStartingPoint } from "../cost";
import { EVALUATING_PATH_COLOR } from "../../constants";
const simulatedAnnealing = async points => {
const sp = points[0];
const path = points;
const tempCoeff =
path.length < 10
? 1 - 1e-4
: path.length < 15
? 1 - 1e-5
: path.length < 30
? 1 - 1e-6
: 1 - 5e-7;
const deltaDistance = (aIdx, bIdx) => {
const aPrev = (aIdx - 1 + path.length) % path.length;
const aNext = (aIdx + 1 + path.length) % path.length;
const bPrev = (bIdx - 1 + path.length) % path.length;
const bNext = (bIdx + 1 + path.length) % path.length;
let diff =
distance(path[bPrev], path[aIdx]) +
distance(path[aIdx], path[bNext]) +
distance(path[aPrev], path[bIdx]) +
distance(path[bIdx], path[aNext]) -
distance(path[aPrev], path[aIdx]) -
distance(path[aIdx], path[aNext]) -
distance(path[bPrev], path[bIdx]) -
distance(path[bIdx], path[bNext]);
if (bPrev === aIdx || bNext === aIdx) {
diff += 2 * distance(path[aIdx], path[bIdx]);
}
return diff;
};
const changePath = temperature => {
// 2 random points
const a = 1 + Math.floor(Math.random() * (path.length - 1));
const b = 1 + Math.floor(Math.random() * (path.length - 1));
const delta = deltaDistance(a, b);
if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) {
// swap points
[path[a], path[b]] = [path[b], path[a]];
}
};
const initialTemp = 100 * distance(path[0], path[1]);
let i = 0;
for (
let temperature = initialTemp;
temperature > 1e-6;
temperature *= tempCoeff
) {
changePath(temperature);
if (i % 10000 == 0) {
self.setEvaluatingPaths(() => ({
paths: [{ path, color: EVALUATING_PATH_COLOR }],
cost: pathCost(path)
}));
await self.sleep();
}
if (i % 100000 == 0) {
path.push(sp);
self.setBestPath(path, pathCost(path));
path.pop();
}
i++;
}
// rotate the array so that starting point is back first
rotateToStartingPoint(path, sp);
// go back home
path.push(sp);
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [{ path }],
cost
}));
self.setBestPath(path, cost);
};
makeSolver(simulatedAnnealing);

View file

@ -0,0 +1,72 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const twoOptInversion = async path => {
path.push(path[0]);
let best = pathCost(path);
let swapped = true;
self.setBestPath(path, best);
while (swapped) {
swapped = false;
for (let pt1 = 1; pt1 < path.length - 1; pt1++) {
for (let pt2 = pt1 + 1; pt2 < path.length - 1; pt2++) {
// section of the path to reverse
const section = path.slice(pt1, pt2 + 1);
// reverse section in place
section.reverse();
// replace section of path with reversed section in place
path.splice(pt1, pt2 + 1 - pt1, ...section);
// calculate new cost
const newPath = path;
const cost = pathCost(newPath);
self.setEvaluatingPaths(() => ({
paths: [
{ path: path.slice(0, pt1), color: EVALUATING_SEGMENT_COLOR },
{ path: path.slice(pt1 + 1, pt2), color: EVALUATING_SEGMENT_COLOR },
{ path: path.slice(pt2 + 1), color: EVALUATING_SEGMENT_COLOR },
{
path: [path[pt1 - 1], path[pt1], path[pt1 + 1]],
color: EVALUATING_PATH_COLOR
},
{
path: [path[pt2 - 1], path[pt2], path[pt2 + 1]],
color: EVALUATING_PATH_COLOR
}
],
cost
}));
await self.sleep();
if (cost < best) {
// found a better path after the swap, keep it
swapped = true;
best = cost;
self.setBestPath(newPath, best);
} else {
// un-reverse the section
section.reverse();
path.splice(pt1, pt2 + 1 - pt1, ...section);
}
self.setEvaluatingPaths(() => ({
paths: [{ path, color: EVALUATING_SEGMENT_COLOR }]
}));
await self.sleep();
}
}
}
};
makeSolver(twoOptInversion);

View file

@ -0,0 +1,65 @@
/* eslint-disable no-restricted-globals */
import makeSolver from "../makeSolver";
import { pathCost } from "../cost";
import {
EVALUATING_PATH_COLOR,
EVALUATING_SEGMENT_COLOR
} from "../../constants";
const twoOptReciprocalExchange = async path => {
path.push(path[0]);
let best = pathCost(path);
let swapped = true;
self.setBestPath(path, best);
while (swapped) {
swapped = false;
for (let pt1 = 1; pt1 < path.length - 1; pt1++) {
for (let pt2 = pt1 + 1; pt2 < path.length - 1; pt2++) {
// swap current pair of points
[path[pt1], path[pt2]] = [path[pt2], path[pt1]];
// calculate new cost
const cost = pathCost(path);
self.setEvaluatingPaths(() => ({
paths: [
{ path: path.slice(0, pt1), color: EVALUATING_SEGMENT_COLOR },
{ path: path.slice(pt1 + 1, pt2), color: EVALUATING_SEGMENT_COLOR },
{ path: path.slice(pt2 + 1), color: EVALUATING_SEGMENT_COLOR },
{
path: [path[pt1 - 1], path[pt1], path[pt1 + 1]],
color: EVALUATING_PATH_COLOR
},
{
path: [path[pt2 - 1], path[pt2], path[pt2 + 1]],
color: EVALUATING_PATH_COLOR
}
],
cost
}));
await self.sleep();
if (cost < best) {
// found a better path after the swap, keep it
swapped = true;
best = cost;
self.setBestPath(path, best);
} else {
// swap back - this one's worse
[path[pt1], path[pt2]] = [path[pt2], path[pt1]];
}
self.setEvaluatingPath(() => ({
path: { path, color: EVALUATING_SEGMENT_COLOR }
}));
await self.sleep();
}
}
}
};
makeSolver(twoOptReciprocalExchange);

31
src/solvers/index.js Normal file
View file

@ -0,0 +1,31 @@
import random from "./exhaustive/random.worker";
import depthFirstSearch from "./exhaustive/depthFirstSearch.worker";
import branchAndBoundOnCost from "./exhaustive/branchAndBoundOnCost.worker";
import branchAndBoundOnCostAndCross from "./exhaustive/branchAndBoundOnCostAndCross.worker";
import nearestNeighbor from "./heuristic-construction/nearestNeighbor.worker";
import arbitraryInsertion from "./heuristic-construction/arbitraryInsertion.worker";
import nearestInsertion from "./heuristic-construction/nearestInsertion.worker";
import furthestInsertion from "./heuristic-construction/furthestInsertion.worker";
import convexHull from "./heuristic-construction/convexHull.worker";
import simulatedAnnealing from "./heuristic-construction/simulatedAnnealing.worker";
import twoOptInversion from "./heuristic-improvement/twoOptInversion.worker";
import twoOptReciprocalExchange from "./heuristic-improvement/twoOptReciprocalExchange.worker";
export default {
random,
depthFirstSearch,
branchAndBoundOnCost,
branchAndBoundOnCostAndCross,
nearestNeighbor,
arbitraryInsertion,
furthestInsertion,
nearestInsertion,
convexHull,
simulatedAnnealing,
twoOptInversion,
twoOptReciprocalExchange
};

91
src/solvers/makeSolver.js Normal file
View file

@ -0,0 +1,91 @@
/* eslint-disable no-restricted-globals */
import * as actions from "../store/actions";
const wrapSolver = solver => async (...args) => {
await solver(...args);
self.postMessage(actions.stopSolvingAction());
};
export const makeSolver = solver => {
const run = wrapSolver(solver);
self.solverConfig = {
detailLevel: 0,
delay: 10,
fullSpeed: false,
paused: false
};
self.setBestPath = (...args) => {
self.postMessage(actions.setBestPath(...args));
};
self.setEvaluatingPaths = (getPaths, level = 1) => {
if (self.solverConfig.detailLevel >= level) {
const { paths, cost } = getPaths();
self.postMessage(actions.setEvaluatingPaths(paths, cost));
}
};
self.setEvaluatingPath = (getPath, level = 1) => {
if (self.solverConfig.detailLevel >= level) {
const { path, cost } = getPath();
self.postMessage(actions.setEvaluatingPath(path, cost));
}
};
self.waitPause = async () => {
while (self.solverConfig.paused) {
await new Promise(resolve => setTimeout(resolve, 500));
}
};
self.sleep = async () => {
if (self.solverConfig.paused) {
return await self.waitPause();
}
const duration = self.solverConfig.fullSpeed
? 0
: self.solverConfig.delay || 10;
return new Promise(resolve => {
setTimeout(resolve, duration);
});
};
self.onmessage = async ({ data: action }) => {
switch (action.type) {
case actions.START_SOLVING:
self.solverConfig.delay = action.delay;
self.solverConfig.detailLevel = action.evaluatingDetailLevel;
self.solverConfig.fullSpeed = action.fullSpeed;
run(action.points);
break;
case actions.SET_DELAY:
self.solverConfig.delay = action.delay;
break;
case actions.SET_EVALUATING_DETAIL_LEVEL:
self.solverConfig.detailLevel = action.level;
break;
case actions.GO_FULL_SPEED:
self.solverConfig.evaluatingDetailLevel = 0;
self.solverConfig.fullSpeed = true;
break;
case actions.PAUSE:
self.solverConfig.paused = true;
break;
case actions.UNPAUSE:
self.solverConfig.paused = false;
break;
default:
throw new Error(`invalid action sent to solver ${action.type}`);
}
};
};
export default makeSolver;

209
src/store/actions.js Normal file
View file

@ -0,0 +1,209 @@
import gtmEmit from "./emitCustomEvent";
export const SET_VIEWPORT_STATE = "SET_VIEWPORT_STATE";
export const RESET_EVALUATING_STATE = "RESET_EVALUATING_STATE";
export const RESET_BEST_PATH_STATE = "RESET_BEST_PATH_STATE";
export const SET_ALGORITHM = "SET_ALGORITHM";
export const SET_DELAY = "SET_DELAY";
export const SET_EVALUATING_DETAIL_LEVEL = "SET_EVALUATING_DETAIL_LEVEL";
export const SET_SHOW_BEST_PATH = "SET_SHOW_BEST_PATH";
export const START_SOLVING = "START_SOLVING";
export const GO_FULL_SPEED = "GO_FULL_SPEED";
export const PAUSE = "PAUSE";
export const UNPAUSE = "UNPAUSE";
export const STOP_SOLVING = "STOP_SOLVING";
export const SET_BEST_PATH = "SET_BEST_PATH";
export const SET_EVALUATING_PATHS = "SET_EVALUATING_PATHS";
export const START_DEFINING_POINTS = "START_DEFINING_POINTS";
export const ADD_DEFINED_POINT = "ADD_DEFINED_POINT";
export const STOP_DEFINING_POINTS = "STOP_DEFINING_POINTS";
export const SET_POINT_COUNT = "SET_POINT_COUNT";
export const SET_POINTS = "SET_POINTS";
export const SET_DEFAULT_MAP = "SET_DEFAULT_MAP";
export const TOGGLE_SITE_INFO_OPEN = "TOGGLE_SITE_INFO_OPEN";
export const TOGGLE_ALG_INFO_OPEN = "TOGGLE_ALG_INFO_OPEN";
const getRandomPoint = (max, min) => Math.random() * (max - min) + min;
//
// BASIC UI
//
export const toggleSiteInfoOpen = () => ({
type: TOGGLE_SITE_INFO_OPEN
});
export const toggleAlgInfoOpen = () => ({
type: TOGGLE_ALG_INFO_OPEN
});
//
// MAP INTERACTION
//
export const setViewportState = viewport => ({
type: SET_VIEWPORT_STATE,
viewport
});
//
// SOLVER CONTROLS
//
const resetEvaluatingStateAction = () => ({
type: RESET_EVALUATING_STATE
});
const resetBestPathStateAction = () => ({
type: RESET_BEST_PATH_STATE
});
const setAlgorithmAction = (algorithm, defaults) => ({
type: SET_ALGORITHM,
algorithm,
defaults
});
export const startSolvingAction = (points, delay, evaluatingDetailLevel) => ({
type: START_SOLVING,
points,
delay,
evaluatingDetailLevel,
fullSpeed: false
});
export const stopSolvingAction = () => ({
type: STOP_SOLVING
});
export const setAlgorithm = (algorithm, defaults = {}) => dispatch => {
dispatch(resetEvaluatingStateAction());
dispatch(setAlgorithmAction(algorithm, defaults));
};
export const setDelay = delay => ({
type: SET_DELAY,
delay
});
export const setEvaluatingDetailLevel = level => ({
type: SET_EVALUATING_DETAIL_LEVEL,
level
});
export const setShowBestPath = show => ({
type: SET_SHOW_BEST_PATH,
show
});
export const resetSolverState = () => dispatch => {
dispatch(stopSolving());
dispatch(resetEvaluatingStateAction());
dispatch(resetBestPathStateAction());
};
export const startSolving = (...args) => (dispatch, getState) => {
const { algorithm, pointCount } = getState();
gtmEmit({
event: "start-solving",
algorithm,
pointCount
});
dispatch(resetEvaluatingStateAction());
dispatch(startSolvingAction(...args));
};
export const goFullSpeed = () => ({
type: GO_FULL_SPEED
});
export const pause = () => ({
type: PAUSE
});
export const unpause = () => ({
type: UNPAUSE
});
export const stopSolving = () => dispatch => {
dispatch(resetEvaluatingStateAction());
dispatch(stopSolvingAction());
};
//
// SOLVER ACTIONS
//
export const setEvaluatingPath = (path, cost) => ({
type: SET_EVALUATING_PATHS,
paths: [path],
cost
});
export const setEvaluatingPaths = (paths, cost) => ({
type: SET_EVALUATING_PATHS,
paths,
cost
});
export const setBestPath = (path, cost) => ({
type: SET_BEST_PATH,
path,
cost
});
//
// POINT CONTROLS
//
const setDefaultMapAction = () => ({
type: SET_DEFAULT_MAP
});
const setPointsAction = points => ({
type: SET_POINTS,
points
});
const setPointCountAction = count => ({
type: SET_POINT_COUNT,
count
});
const startDefiningPointsAction = () => ({
type: START_DEFINING_POINTS
});
export const startDefiningPoints = () => dispatch => {
dispatch(resetSolverState());
dispatch(startDefiningPointsAction());
};
export const addDefinedPoint = point => ({
type: ADD_DEFINED_POINT,
point
});
export const stopDefiningPoints = () => ({
type: STOP_DEFINING_POINTS
});
export const setPointCount = count => dispatch => {
dispatch(resetSolverState());
dispatch(setPointCountAction(count));
};
export const randomizePoints = bounds => (dispatch, getState) => {
const { pointCount } = getState();
const { top, bottom, left, right } = bounds;
const points = Array.from({ length: pointCount }).map(_ => [
getRandomPoint(right, left),
getRandomPoint(top, bottom)
]);
dispatch(resetSolverState());
dispatch(setPointsAction(points));
};
export const setDefaultMap = (...args) => dispatch => {
dispatch(resetSolverState());
dispatch(setDefaultMapAction());
};

View file

@ -0,0 +1,7 @@
export default ev => {
if (typeof window !== "undefined" && window.dataLayer) {
window.dataLayer.push(ev);
} else {
console.log(ev);
}
};

242
src/store/reducer.js Normal file
View file

@ -0,0 +1,242 @@
import * as actions from "./actions";
const usTop12 = [
[-73.85835427500902, 40.56507951957753],
[-77.54976052500858, 38.772432514145194],
[-78.91206521250587, 42.66742768420476],
[-70.95796365000933, 42.66742768420476],
[-80.27436990000314, 26.176558881220437],
[-84.4052292750001, 34.108547937473524],
[-82.55952615000031, 28.24770207922181],
[-84.66890115000008, 30.089457425014395],
[-89.89839333750201, 29.746655988569763],
[-96.62202615000125, 32.640688397241334],
[-95.3036667750014, 29.287759374472813],
[-97.76460427500368, 30.089457425014395],
[-101.89546365000065, 34.97727964358472],
[-112.22261208749687, 33.23080293029681],
[-111.38765114999953, 35.01327961148759],
[-115.56245583750162, 36.08588188690158],
[-118.63862771249869, 33.999320468363095],
[-117.2323777124963, 32.97311239658548],
[-123.12104958749816, 38.222145234071036],
[-124.26362771250061, 41.13019627380825],
[-120.13276833749595, 39.72528830651809],
[-111.82710427499693, 41.13019627380825],
[-105.2353073999977, 39.961475963760066],
[-87.43745583749975, 41.69048709677229],
[-93.1064011499991, 45.29144400095841],
[-90.20601052499944, 38.772432514145194],
[-117.27632302500142, 47.50341272285311],
[-122.72554177499823, 45.8757982618686],
[-122.81343240000076, 48.152468818056875]
];
const initialViewport = {
latitude: 39.8097343,
longitude: -98.5556199,
zoom: 4
};
const initialState = {
points: usTop12.sort(() => Math.random() + 0.5),
viewport: initialViewport,
algorithm: "convexHull",
delay: 100,
evaluatingDetailLevel: 2,
maxEvaluatingDetailLevel: 2,
showBestPath: true,
bestPath: [],
bestDisplaySegments: [],
bestCost: null,
evaluatingPaths: [],
evaluatingCost: null,
running: false,
fullSpeed: false,
paused: false,
startedRunningAt: null,
pointCount: usTop12.length,
definingPoints: false,
siteInfoOpen: false,
algInfoOpen: false
};
export default (state = initialState, action) => {
switch (action.type) {
case actions.TOGGLE_SITE_INFO_OPEN:
return {
...state,
siteInfoOpen: !state.siteInfoOpen
};
case actions.TOGGLE_ALG_INFO_OPEN:
return {
...state,
algInfoOpen: !state.algInfoOpen
};
case actions.SET_VIEWPORT_STATE:
return {
...state,
viewport: action.viewport
};
case actions.RESET_EVALUATING_STATE:
return {
...state,
evaluatingPaths: [],
evaluatingCost: null
};
case actions.RESET_BEST_PATH_STATE:
return {
...state,
bestPath: [],
bestCost: null
};
//
// SOLVER CONTROLS
//
case actions.SET_ALGORITHM:
return {
...state,
...action.defaults,
algorithm: action.algorithm
};
case actions.SET_DELAY:
return {
...state,
delay: action.delay
};
case actions.SET_EVALUATING_DETAIL_LEVEL:
return {
...state,
evaluatingDetailLevel: action.level,
evaluatingPaths: action.level ? state.evaluatingPaths : [],
evaluatingCost: action.level ? state.evaluatingCost : null
};
case actions.SET_SHOW_BEST_PATH:
return {
...state,
showBestPath: action.show
};
case actions.START_SOLVING:
return {
...state,
showBestPath: false,
running: true,
startedRunningAt: Date.now(),
pointCount: state.points.length
};
case actions.GO_FULL_SPEED:
return {
...state,
showBestPath: true,
evaluatingDetailLevel: 0,
evaluatingPaths: [],
fullSpeed: true
};
case actions.PAUSE:
return {
...state,
paused: true,
running: false
};
case actions.UNPAUSE:
return {
...state,
paused: false,
running: true
};
case actions.STOP_SOLVING:
return {
...state,
points:
state.bestPath.length > 0
? state.bestPath.slice(0, state.bestPath.length - 1)
: state.points,
showBestPath: true,
running: false,
paused: false,
fullSpeed: false,
startedRunningAt: null
};
//
// SOLVER ACTIONS
//
case actions.SET_EVALUATING_PATHS:
return {
...state,
evaluatingPaths: state.evaluatingDetailLevel ? action.paths : [],
evaluatingCost: state.evaluatingDetailLevel ? action.cost : null
};
case actions.SET_BEST_PATH:
return {
...state,
bestPath: action.path,
bestCost: action.cost
};
//
// POINT CONTROLS
//
case actions.SET_POINT_COUNT:
return {
...state,
pointCount: action.count
};
case actions.SET_POINTS:
return {
...state,
points: action.points
};
case actions.START_DEFINING_POINTS:
return {
...state,
points: [],
definingPoints: true,
pointCount: 0
};
case actions.ADD_DEFINED_POINT:
return {
...state,
points: [...state.points, action.point],
pointCount: state.pointCount + 1
};
case actions.STOP_DEFINING_POINTS:
return {
...state,
definingPoints: false
};
case actions.SET_DEFAULT_MAP:
return {
...state,
viewport: initialViewport,
points: usTop12,
pointCount: usTop12.length
};
default:
return state;
}
};

94
src/store/selectors.js Normal file
View file

@ -0,0 +1,94 @@
import { createSelector } from "reselect";
import {
START_POINT_COLOR,
POINT_COLOR,
BEST_PATH_COLOR,
EVALUATING_PATH_COLOR
} from "../constants";
//
// FOR UI
//
export const selectSiteInfoOpen = state => state.siteInfoOpen;
export const selectAlgInfoOpen = state => state.algInfoOpen;
//
// FOR SOLVER CONTROLS
//
export const selectAlgorithm = state => state.algorithm;
export const selectDelay = state => state.delay;
export const selectEvaluatingDetailLevel = state => state.evaluatingDetailLevel;
export const selectMaxEvaluatingDetailLevel = state =>
state.maxEvaluatingDetailLevel;
export const selectRunning = state => state.running;
export const selectFullSpeed = state => state.fullSpeed;
export const selectPaused = state => state.paused;
export const selectStartedRunningAt = state => state.startedRunningAt;
//
// FOR POINT CONTROLS
//
export const selectDefiningPoints = state => state.definingPoints;
export const selectPointCount = state => state.pointCount;
//
// FOR PLOT
//
export const selectViewport = state => state.viewport;
export const selectPoints = state => state.points;
export const selectPointsDisplay = createSelector(selectPoints, points =>
points.map((p, idx) => ({
position: p,
color: idx === 0 ? START_POINT_COLOR : POINT_COLOR
}))
);
export const selectShowBestPath = state => state.showBestPath;
export const selectBestPath = state => state.bestPath;
export const selectBestPathDisplay = createSelector(
selectBestPath,
selectShowBestPath,
(path, show) => ({
path: show ? path : [],
color: BEST_PATH_COLOR,
width: 20
})
);
export const selectBestCost = state => state.bestCost;
export const selectBestCostDisplay = createSelector(selectBestCost, cost =>
cost ? cost.toFixed(2) : ""
);
export const selectEvaluatingPaths = state => state.evaluatingPaths;
export const selectEvaluatingPathsDisplay = createSelector(
selectEvaluatingPaths,
paths =>
paths.map(({ path, color }) => ({
path,
color: color || EVALUATING_PATH_COLOR,
width: 5
}))
);
export const selectEvaluatingCost = state => state.evaluatingCost;
export const selectEvaluatingCostDisplay = createSelector(
selectEvaluatingCost,
cost => (cost ? cost.toFixed(2) : "")
);
export const selectPlotPaths = createSelector(
selectBestPathDisplay,
selectEvaluatingPathsDisplay,
(bestPath, evaluatingPaths) => [...evaluatingPaths, bestPath]
);

5
src/store/store.js Normal file
View file

@ -0,0 +1,5 @@
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from "./reducer";
export default createStore(reducer, applyMiddleware(thunk));

View file

@ -0,0 +1,4 @@
This is a Brave Rewards publisher verification file.
Domain: tspvis.com
Token: b687d7e75fc19d979ab3ab8b264164a91d8206d0bb2ffa33334069d9092406b1

1
static/CNAME Normal file
View file

@ -0,0 +1 @@
tspvis.com

2
static/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /