diff --git a/cypress/integration/Map-spec.js b/cypress/integration/Map-spec.js index 76acf18..280f8c6 100644 --- a/cypress/integration/Map-spec.js +++ b/cypress/integration/Map-spec.js @@ -48,4 +48,13 @@ describe('Map', () => { .click() cy.contains('TRAVEL_PROMPT').should('not.exist') }) + + it('Should be able to instantaneously travel to a planet.', () => { + const destinationPlanet = mockState.world.planets.find( + planet => planet.name !== mockState.ship.location.name + ) + cy.contains(destinationPlanet.name).click() + cy.contains('TRAVEL').click() + // ! Assert that the new planet is traveled to + }) }) diff --git a/src/components/Map.js b/src/components/Map.js index c23bd25..282af99 100644 --- a/src/components/Map.js +++ b/src/components/Map.js @@ -4,202 +4,103 @@ import { Paper } from '@material-ui/core' import * as d3 from 'd3' import { connect } from 'react-redux' import TravelPrompt from './TravelPrompt' - -const radius = 23 -const fill = '#1976d2' -const fillHover = 'rgb(17, 82, 147)' - -const createSvg = (selector, height, width) => - d3 - .select(selector) - .append('svg') - .attr('height', height) - .attr('width', width) - -const createSimulation = nodes => d3.forceSimulation().nodes(nodes) - -const updateLinks = link => - link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) - -const addForces = (simulation, height, width) => - simulation - .force('charge_force', d3.forceManyBody()) - .force('center_force', d3.forceCenter(width / 2, height / 2)) - -const createNode = (svg, data) => - svg - .append('g') - .attr('class', 'nodes') - .selectAll('circle') - .data(data) - .enter() - .append('g') - .attr('class', planet => - planet.isHomePlanet ? 'node-container home-planet' : 'node-container' - ) - .attr('style', planet => (planet.isHomePlanet ? 'cursor: default' : '')) - .attr('id', planet => planet.name) - .append('circle') - .attr('r', radius) - .attr('fill', fill) - -const createLink = (svg, data) => - svg - .append('g') - .attr('class', 'links') - .selectAll('line') - .data(data) - .enter() - .append('line') - .attr('stroke-width', 2) - -const addEventsToNodes = (svg, setOpen) => { - svg.selectAll('.node-container').on('mouseover', function(data) { - // * Function has in scope: data, d3.event, d3.mouse(this), this - - if (!data.isHomePlanet) { - d3.select(this) - .select('text') - .style('font-size', '20px') - d3.select(this) - .select('circle') - .attr('fill', fillHover) - } - }) - svg.selectAll('.node-container').on('mouseleave', function(data) { - d3.select(this) - .select('circle') - .attr('fill', fill) - d3.select(this) - .select('text') - .style('font-size', '16px') - }) - svg.selectAll('.node-container').on('click', function(data) { - if (!data.isHomePlanet) setOpen(true) - }) -} - -const Map = ({ planets, ship }) => { +import { + addEventsToNodes, + createHomePlanetInd, + createLabels, + createLinks, + createNodes, + createShipInd, + createSimulation, + createSvg, + getHeight, + getWidth, + updateShipLocation +} from '../util/map' + +/** + * TODO - Edit generatePlanets to modify the location to be a random x and random y + * TODO - Modify the calculation for centering elements on the circle x. Needs to include calculation for width of element itself + */ +const Map = ({ currentShipLocation, planets, ship }) => { const drawChart = () => { - const height = d3.select('#map-root').property('clientHeight') - const width = d3.select('#map-root').property('clientWidth') + const height = getHeight('#map-root') + const width = getWidth('#map-root') const svg = createSvg('#map-root', height, width) - const nodes_data = planets - - const simulation = createSimulation(nodes_data) - - const handleTick = () => { - //update circle positions each tick of the simulation - node.attr('cx', d => d.x).attr('cy', d => d.y) + // TODO - Incorporate this into generatePlanets() + const planet1Location = { x: -0.25, y: 0 } + const planet2Location = { x: 0, y: 0 } + const planet3Location = { x: 0.25, y: 0 } + const mockPlanetLocations = [ + planet1Location, + planet2Location, + planet3Location + ] - textLabels - .attr('x', ({ x }) => x + radius + 2) - .attr('y', ({ y }) => y + radius / 2) + const nodes_data = planets.map((planet, i) => ({ + ...planet, + location: mockPlanetLocations[i] + })) - homePlanetInd - .attr('x', ({ x }) => x + radius + 2) - .attr('y', ({ y }) => y - 10) + const links_data = [ + { source: nodes_data[0].name, target: nodes_data[1].name }, + { source: nodes_data[1].name, target: nodes_data[2].name } + ] - shipInd.attr('x', ({ x }) => x + radius + 2).attr('y', ({ y }) => y + 30) + createSimulation(nodes_data) - //update link positions - //simply tells one end of the line to follow one node around - //and the other end of the line to follow the other node around - updateLinks(link) - } - //add forces - //we're going to add a charge to each node - //also going to add a centering force - addForces(simulation, height, width) + createNodes(svg, nodes_data, currentShipLocation, height, width) - //draw circles for the nodes - const node = createNode(svg, nodes_data) + createLinks(svg, links_data, height, width) - //add tick instructions: - simulation.on('tick', handleTick) + createLabels(svg, height, width) - //Time for the links + createHomePlanetInd(svg, planets, height, width) - //Create links data - const links_data = [ - { source: planets[0].name, target: planets[1].name, distance: 5 }, - { source: planets[1].name, target: planets[2].name, distance: 10 } - ] + createShipInd(svg, ship, height, width) - //Create the link force - //We need the id accessor to use named sources and targets - - const link_force = d3 - .forceLink(links_data) - .id(d => d.name) - .distance(200) - - //Add a links force to the simulation - //Specify links in d3.forceLink argument - - simulation.force('links', link_force) - simulation.force('manyBody', d3.forceManyBody().strength(-800)) - - //draw lines for the links - const link = createLink(svg, links_data) - - const textLabels = svg - .selectAll('.node-container') - .append('text') - .text(({ name }) => name) - .attr('x', ({ x }) => x) - .attr('y', ({ y }) => y) - - // Indicate home planet - const homePlanet = planets.find(planet => planet.isHomePlanet === true) - const homePlanetInd = svg - .select(`#${homePlanet.name}`) - .append('text') - .text('(Home Planet)') - .attr('id', 'home-planet-ind') - .attr('x', ({ x }) => x) - .attr('y', ({ y }) => y) - .style('font-size', '10px') - - const shipInd = svg - .select(`#${ship.location.name}`) - .append('text') - .text('(YOUR_SHIP)') - .attr('id', 'ship-ind') - .attr('x', ({ x }) => x) - .attr('y', ({ y }) => y) - .style('font-size', '10px') - - addEventsToNodes(svg, setOpen) + addEventsToNodes(svg, setDestination, setOpen, currentShipLocation) } + const [open, setOpen] = useState(false) + const [destination, setDestination] = useState({}) + useEffect(() => { drawChart() // eslint-disable-next-line }, []) - const [open, setOpen] = useState(false) + useEffect(() => { + updateShipLocation( + currentShipLocation, + setOpen, + setDestination, + d3.select('#map-root').property('clientHeight'), + d3.select('#map-root').property('clientWidth') + ) + }, [currentShipLocation]) return ( - + ) } Map.propTypes = { + currentShipLocation: PropTypes.object.isRequired, planets: PropTypes.array.isRequired, ship: PropTypes.object.isRequired } -const mapStateToProps = ({ ship, world }) => ({ planets: world.planets, ship }) +const mapStateToProps = ({ ship, world }) => ({ + currentShipLocation: ship.location, + planets: world.planets, + ship +}) const mapDispatchToProps = dispatch => ({}) diff --git a/src/components/MarketTable.js b/src/components/MarketTable.js index 3246d47..1ed0125 100644 --- a/src/components/MarketTable.js +++ b/src/components/MarketTable.js @@ -9,7 +9,7 @@ import { TableSortLabel } from '@material-ui/core' import ItemCard from '../components/ItemCard' -import { simpleCompare } from '../util' +import { simpleCompare } from '../util/main' import MarketAvatar from './MarketAvatar' const MarketTable = ({ data, item }) => { diff --git a/src/components/PlanetDisplay.js b/src/components/PlanetDisplay.js index 7329a1c..3a7dca1 100644 --- a/src/components/PlanetDisplay.js +++ b/src/components/PlanetDisplay.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux' import { Box, Button, Heading, Text } from 'grommet' import { Target } from 'grommet-icons' import { departShip } from '../redux/actions/ship' -import { createETA, createDiffDuration } from '../util' +import { createETA, createDiffDuration } from '../util/main' /** * Displays planet statistics. diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 9abce7f..0673a71 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -8,7 +8,7 @@ import { views } from '../constants' import Title from './Title' import TravelTimer from './TravelTimer' import CashDisplay from './CashDisplay' -import { exportGame } from '../util' +import { exportGame } from '../util/main' import ImportButton from './ImportButton' import { Tag } from 'flwww' diff --git a/src/components/TravelPrompt.js b/src/components/TravelPrompt.js index 8d0d6b6..9a71043 100644 --- a/src/components/TravelPrompt.js +++ b/src/components/TravelPrompt.js @@ -1,12 +1,17 @@ import React from 'react' +import PropTypes from 'prop-types' import { + Button, Dialog, + DialogActions, DialogContent, DialogContentText, DialogTitle } from '@material-ui/core' +import { connect } from 'react-redux' +import { instantTravel } from '../redux/actions/ship' -const TravelPrompt = ({ open, setOpen }) => { +const TravelPrompt = ({ destination, handleTravel, open, setOpen }) => { const handleClose = () => setOpen(false) return ( @@ -16,8 +21,38 @@ const TravelPrompt = ({ open, setOpen }) => { Are you sure you want to travel to PLANET_NAME? + + + + ) } -export default TravelPrompt +TravelPrompt.propTypes = { + destination: PropTypes.object.isRequired, + handleTravel: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + setOpen: PropTypes.func.isRequired +} + +const mapStateToProps = () => ({}) + +const mapDispatchToProps = dispatch => ({ + handleTravel: (destination, setOpen) => { + dispatch(instantTravel(destination)) + setOpen(false) + } +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TravelPrompt) diff --git a/src/redux/actions/ship.js b/src/redux/actions/ship.js index 9551da9..c2ccfa5 100644 --- a/src/redux/actions/ship.js +++ b/src/redux/actions/ship.js @@ -1,6 +1,6 @@ import { addCash, removeCash } from './user' import { removeItem } from './world' -import { createETA, createDiffDuration } from '../../util' +import { createETA, createDiffDuration } from '../../util/main' import moment from 'moment' // * ACTION TYPES @@ -188,3 +188,7 @@ export const purchaseCargo = (item, quantity) => dispatch => { // * dispatch an action to remove the item from the list of stored items on this planet dispatch(removeItem(item, quantity)) } + +export const instantTravel = destination => dispatch => { + dispatch(setShipLocation({ name: destination.name })) +} diff --git a/src/redux/actions/world.js b/src/redux/actions/world.js index 71f05e2..bba5681 100644 --- a/src/redux/actions/world.js +++ b/src/redux/actions/world.js @@ -1,5 +1,5 @@ import { setShipLocation } from './ship' -import { generateBuyers, generatePlanets, generateSellers } from '../../util' +import { generateBuyers, generatePlanets, generateSellers } from '../../util/main' import { setBuyers, setSellers } from './market' // * ACTION TYPES diff --git a/src/redux/reducers/world.js b/src/redux/reducers/world.js index 76ece29..9b2ec2b 100644 --- a/src/redux/reducers/world.js +++ b/src/redux/reducers/world.js @@ -1,4 +1,4 @@ -import { generateItems } from '../../util' +import { generateItems } from '../../util/main' const worldDefaultState = { isTimerRunning: false, diff --git a/src/redux/store/store.js b/src/redux/store/store.js index 1eb0453..6205a74 100644 --- a/src/redux/store/store.js +++ b/src/redux/store/store.js @@ -5,7 +5,7 @@ import shipReducer from '../reducers/ship' import uiReducer from '../reducers/ui' import userReducer from '../reducers/user' import worldReducer from '../reducers/world' -import { loadState, saveState } from '../../util' +import { loadState, saveState } from '../../util/main' import throttle from 'lodash/throttle' const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose diff --git a/src/util.js b/src/util/main.js similarity index 99% rename from src/util.js rename to src/util/main.js index 1d40842..562b567 100644 --- a/src/util.js +++ b/src/util/main.js @@ -1,4 +1,4 @@ -import { itemList, planets, firstNames, lastNames, colors } from './constants' +import { itemList, planets, firstNames, lastNames, colors } from '../constants' import uuidv4 from 'uuid/v4' import moment from 'moment' import { saveAs } from 'file-saver' diff --git a/src/util/map.js b/src/util/map.js new file mode 100644 index 0000000..5616b92 --- /dev/null +++ b/src/util/map.js @@ -0,0 +1,188 @@ +import * as d3 from 'd3' + +const radius = 23 +const fill = '#1976d2' +const fillHover = 'rgb(17, 82, 147)' + +export const createSvg = (selector, height, width) => + d3 + .select(selector) + .append('svg') + .attr('height', height) + .attr('width', width) + +export const createSimulation = data => + d3 + .forceSimulation() + .nodes(data) + .stop() + +export const modifyCursor = (selection, currentShipLocation) => + selection.attr('style', ({ name }) => + name !== currentShipLocation.name ? 'cursor: pointer' : 'cursor: default' + ) + +const createCircle = (selection, height, width) => + selection + .append('circle') + .attr('r', radius) + .attr('fill', fill) + .attr('cx', ({ location }) => width / 2 + location.x * width) + .attr('cy', ({ location }) => height / 2 - location.y * height) + +export const createNodes = (svg, data, currentShipLocation, height, width) => { + const selection = svg + .append('g') + .attr('class', 'nodes') + .selectAll('circle') + .data(data) + .enter() + .append('g') + .attr('class', 'node-container') + .attr('id', ({ name }) => name) + + modifyCursor(selection, currentShipLocation) + createCircle(selection, height, width) + + return selection +} + +export const createLinks = (svg, data, height, width) => + svg + .append('g') + .attr('class', 'links') + .selectAll('line') + .data(data) + .enter() + .append('line') + .attr('stroke-width', 2) + .attr( + 'x1', + ({ source }) => + width / 2 + + svg.select(`#${source}`).data()[0].location.x * width + + radius + ) + .attr( + 'y1', + ({ source }) => + height / 2 + svg.select(`#${source}`).data()[0].location.y * height + ) + .attr( + 'x2', + ({ target }) => + width / 2 + + svg.select(`#${target}`).data()[0].location.x * width - + radius + ) + .attr( + 'y2', + ({ target }) => + height / 2 + svg.select(`#${target}`).data()[0].location.y * height + ) + +export const createLabels = (svg, height, width) => + svg + .selectAll('.node-container') + .append('text') + .text(({ name }) => name) + .attr('x', ({ location }) => width / 2 + location.x * width - radius) + .attr('y', ({ location }) => height / 2 + location.y * height + radius + 20) + +export const createHomePlanetInd = (svg, planets, height, width) => { + const homePlanet = planets.find(planet => planet.isHomePlanet === true) + + const selection = svg + .select(`#${homePlanet.name}`) + .append('text') + .text('(Home Planet)') + .attr('id', 'home-planet-ind') + .style('font-size', '10px') + .attr('x', ({ location }) => width / 2 + location.x * width - radius - 4) + .attr('y', ({ location }) => height / 2 + location.y * height + radius + 40) + + return selection +} + +export const createShipInd = (svg, ship, height, width) => + svg + .select(`#${ship.location.name}`) + .append('text') + .text('(YOUR_SHIP)') + .attr('id', 'ship-ind') + .style('font-size', '10px') + .attr('x', ({ location }) => width / 2 + location.x * width - radius - 8) + .attr('y', ({ location }) => height / 2 + location.y * height - radius - 10) + +export const addEventsToNodes = ( + svg, + setDestination, + setOpen, + currentShipLocation +) => { + svg.selectAll('.node-container').on('mouseover', function(planet) { + // * Function has in scope: data, d3.event, d3.mouse(this), this + + if (planet.name !== currentShipLocation.name) { + d3.select(this) + .select('text') + .style('font-size', '20px') + d3.select(this) + .select('circle') + .attr('fill', fillHover) + } + }) + svg.selectAll('.node-container').on('mouseleave', function(planet) { + d3.select(this) + .select('circle') + .attr('fill', fill) + d3.select(this) + .select('text') + .style('font-size', '16px') + }) + svg.selectAll('.node-container').on('click', function(planet) { + if (planet.name !== currentShipLocation.name) { + setDestination(planet) + setOpen(true) + } + }) +} + +export const updateShipLocation = ( + currentShipLocation, + setOpen, + setDestination, + height, + width +) => { + const svg = d3.select('svg') + const destinationNode = svg.select(`#${currentShipLocation.name}`) + + svg + .select(`#ship-ind`) + .attr( + 'x', + () => + width / 2 + destinationNode.data()[0].location.x * width - radius - 8 + ) + .attr( + 'y', + () => + height / 2 + destinationNode.data()[0].location.y * height - radius - 10 + ) + + // * Remove event listeners from nodes + svg.selectAll('.node-container').on('mouseover', null) + svg.selectAll('.node-container').on('mouseleave', null) + svg.selectAll('.node-container').on('click', null) + + addEventsToNodes(svg, setDestination, setOpen, currentShipLocation) + + // * Update cursor stuff + modifyCursor(svg.selectAll('.node-container'), currentShipLocation) +} + +export const getHeight = selector => + d3.select(selector).property('clientHeight') + +export const getWidth = selector => d3.select(selector).property('clientWidth')