├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.coffee ├── lib └── main.coffee ├── package-lock.json ├── package.json ├── public ├── d3.v3.min.js ├── index.html ├── jquery-1.9.1.min.js ├── svg.css └── underscore-min.js └── script ├── app-env ├── bootstrap ├── build ├── clean └── server /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/compiled 3 | dist 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-jessie 2 | RUN touch /etc/inside-container 3 | WORKDIR /jps-explained 4 | CMD ["bash"] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Nathan Witmer 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jump Point Search Explained 2 | 3 | There here mess o' coffeescript is for a blog post on 4 | [zerowidth.com](http://zerowidth.com) explaining how JPS works. The 5 | code ain't pretty, but I tried to make certain pieces reusable for both static 6 | and interactive diagrams. 7 | 8 | `index.html` contains draft versions of most of the diagrams I ended up using. 9 | 10 | I wouldn't suggest dropping this wholesale into your own javascript project, as 11 | the search algorithm implementation was designed with visualization in mind, not 12 | efficiency. Also there's some pretty tight coupling between all the objects 13 | here. However, I hope it can serve as a reference, especially the 14 | `JumpPointSuccessors` bit. For another take, this time in questionable clojure 15 | code, see my [hansel project](https://github.com/zerowidth/hansel). If you need 16 | a real javascript pathfinding library, see 17 | [PathFinding.js](https://github.com/qiao/PathFinding.js). 18 | 19 | This code is symlinked into my jekyll blog (currently private) for inclusion in 20 | the final post. 21 | 22 | To run this thing: 23 | 24 | ```sh 25 | script/server 26 | ``` 27 | 28 | and open [http://localhost:3000](http://localhost:3000). 29 | 30 | Released under the MIT license. 31 | -------------------------------------------------------------------------------- /app.coffee: -------------------------------------------------------------------------------- 1 | express = require "express" 2 | coffeescript = require "connect-coffee-script" 3 | 4 | app = express() 5 | 6 | app.use express.logger() 7 | app.use coffeescript src: "lib", dest: "public/compiled", prefix: "/compiled", force: true 8 | app.use express.static "public" 9 | app.get "/", (req, res) -> 10 | res.sendfile "public/index.html" 11 | 12 | app.listen 3000 13 | console.log "http://localhost:3000" 14 | -------------------------------------------------------------------------------- /lib/main.coffee: -------------------------------------------------------------------------------- 1 | $ -> 2 | 3 | window.diagrams = $('.grid-diagram').map (i, el) -> 4 | new GridDiagram $(el), 24 5 | 6 | window.interactive = $('.interactive-container').map (i, el) -> 7 | new InteractiveGrid $(el), 24 8 | 9 | class InteractiveGrid 10 | constructor: (element, size = 20) -> 11 | $el = $ element 12 | 13 | $grid = $el.find('.interactive-grid') 14 | $grid.html "" 15 | 16 | [width, height] = _.map $grid.data("size").split(","), (n) -> parseInt(n) 17 | 18 | points = [] 19 | for y in [0...height] 20 | for x in [0...width] 21 | points.push [x,y,'clear'] 22 | 23 | y = Math.floor height / 2 24 | start = [2, y] 25 | goal = [width - 3, y] 26 | 27 | @grid = new Grid $grid, width, height, size 28 | @map = new Map @grid, points, start, goal, true 29 | @annotations = new Annotations @grid 30 | @map.draw() 31 | 32 | $el.find('button.start').click => 33 | $el.find('button.start').hide() 34 | $el.find('button.stop').show() 35 | $el.find('button.reset').hide() 36 | jps = $el.find('input[type=checkbox]').is(':checked') 37 | @anim = new AnimatedSearch @map, @annotations, jps, 100 38 | @anim.run -> 39 | $el.find('button.stop').hide() 40 | $el.find('button.start').show() 41 | $el.find('button.reset').show() 42 | 43 | $el.find('button.stop').click => 44 | $el.find('button.stop').hide() 45 | $el.find('button.start').show() 46 | $el.find('button.reset').show() 47 | @anim.finished = true if @anim 48 | 49 | $el.find('button.reset').click => 50 | $el.find('button.reset').hide() 51 | @annotations.reset() 52 | @map.editable = true 53 | 54 | update: (state) => 55 | open = state.open 56 | closed = state.closed 57 | paths = [] 58 | previous = [] 59 | start = @map.start() 60 | goal = @map.goal() 61 | current = state.current 62 | @annotations.update open, closed, paths, previous, start, goal, current 63 | 64 | class AnimatedSearch 65 | constructor: (@map, @annotations, jps=false, @delay=200) -> 66 | @finished = false 67 | neighborStrategy = if jps then JumpPointSuccessors else ImmediateNeighbors 68 | @path = new PathFinder @map, neighborStrategy 69 | 70 | run: (@callback) => 71 | @map.editable = false 72 | @annotations.reset() 73 | setTimeout @tick, @delay 74 | 75 | tick: => 76 | if @finished 77 | @callback() if @callback? 78 | return 79 | 80 | @finished = @path.step() 81 | state = @path.state() 82 | @annotations.update( 83 | state.open, 84 | state.closed, 85 | state.paths, 86 | state.previous, 87 | @map.start(), 88 | @map.goal(), 89 | state.current 90 | null, 91 | state.examined 92 | ) 93 | 94 | setTimeout @tick, @delay 95 | 96 | class GridDiagram 97 | constructor: (element, size = 20) -> 98 | $el = $ element 99 | $el.html "" 100 | 101 | [width, height] = _.map $el.data("size").split(","), (n) -> parseInt(n) 102 | 103 | @grid = new Grid $el, width, height, size 104 | 105 | blocked = @expandList $el.data 'blocked' 106 | 107 | points = [] 108 | for y in [0...height] 109 | for x in [0...width] 110 | if _.indexOf(blocked, x + y * width) is -1 111 | points.push [x,y,'clear'] 112 | else 113 | points.push [x,y,'blocked'] 114 | 115 | paths = [] 116 | if $el.data 'paths' 117 | paths = @pathsToPoints $el.data('paths') 118 | 119 | previous = [] 120 | if $el.data 'previous' 121 | previous = @pathsToPoints $el.data('previous') 122 | 123 | examined = [] 124 | if $el.data 'examined' 125 | examined = @pathsToPoints $el.data('examined') 126 | 127 | open = [] 128 | if $el.data('open')? 129 | open = _.map(@expandList($el.data('open')), @grid.fromOffset) 130 | 131 | closed = [] 132 | if $el.data('closed')? 133 | closed = _.map(@expandList($el.data('closed')), @grid.fromOffset) 134 | 135 | forced = [] 136 | if $el.data('forced')? 137 | forced = _.map(@expandList($el.data('forced')), @grid.fromOffset) 138 | 139 | start = if $el.data('start')? then @grid.fromOffset parseInt $el.data('start') 140 | goal = if $el.data('goal')? then @grid.fromOffset parseInt $el.data('goal') 141 | current = if $el.data('current')? then @grid.fromOffset parseInt $el.data('current') 142 | 143 | @map = new Map @grid, points, start, goal 144 | @annotations = new Annotations @grid 145 | 146 | $el.show() 147 | @map.draw() 148 | @annotations.update open, closed, paths, previous, start, goal, current, 149 | forced, examined 150 | 151 | expandList: (list) -> 152 | parts = _.map "#{list}".split(","), (part) -> 153 | [start, end] = part.split "-" 154 | if end 155 | x = _.range parseInt(start), parseInt(end) + 1 156 | else 157 | parseInt(start) 158 | _.flatten parts 159 | 160 | pathsToPoints: (list) => 161 | _.map list.split(","), (pair) => 162 | _.map pair.split("-"), _.compose(@grid.fromOffset, (s) -> parseInt s) 163 | 164 | class Grid 165 | constructor: (el, @width, @height, @size) -> 166 | @el = el.get 0 # need raw DOM node 167 | @container = d3.select @el 168 | @appendSVGElements() 169 | 170 | @mapSelection = @container.select '.map' 171 | @annotationSelection = @container.select '.annotations' 172 | 173 | offset: (x, y) => 174 | if x >= 0 and x < @width and y >= 0 and y < @height 175 | x + y * @width 176 | 177 | fromOffset: (offset) => 178 | [offset % @width, Math.floor(offset / @width)] 179 | 180 | normalizedCoords: (coords) => 181 | [x, y] = coords 182 | x = Math.floor(x / @size) 183 | y = Math.floor(y / @size) 184 | 185 | if x >= 0 and x < @width and y >= 0 and y < @height 186 | [x, y] 187 | else 188 | null 189 | 190 | appendSVGElements: => 191 | # size is 2px bigger to leave room for outside lines on grid 192 | svg = @container.append 'svg:svg' 193 | svg.attr 'width', @width * @size + 2 194 | svg.attr 'height', @height * @size + 2 195 | 196 | translate = svg.append('svg:g') 197 | # push down by height plus 1px to leave room for lines 198 | .attr('transform', "translate(1,#{@height * @size + 1})") 199 | 200 | # flip vertically 201 | translate.append('svg:g') 202 | .attr('transform', 'scale(1,-1)') 203 | .attr('class', 'map') 204 | 205 | annotations = translate.append('svg:g') 206 | .attr('transform', 'scale(1,-1)') 207 | .attr('class', 'annotations') 208 | 209 | annotations.append('svg:g').attr('class', 'squares') 210 | annotations.append('svg:g').attr('class', 'extras') 211 | annotations.append('svg:g').attr('class', 'paths') 212 | 213 | @mapElement = $(@el).find('.map').get 0 214 | 215 | class Map 216 | constructor: (@grid, @points, start, goal, interactive=false) -> 217 | 218 | @updatePoint start, 'start' if start 219 | @updatePoint goal, 'goal' if goal 220 | 221 | @editable = interactive 222 | @drag = null # what's being dragged, if anything 223 | 224 | $('body').on 'mouseup', @mouseup 225 | $('body').on 'touchend', @mouseup 226 | $('body').on 'touchcancel', @mouseup 227 | 228 | updatePoint: (point, type) => 229 | [x, y] = point 230 | offset = @grid.offset x, y 231 | @points[ offset ][2] = type 232 | 233 | reachable: (from, to) => 234 | [x1, y1] = from 235 | [x2, y2] = to 236 | dx = x2 - x1 237 | dy = y2 - y1 238 | 239 | @isClear(to) and ( 240 | (dx is 0 or dy is 0) or 241 | (@isClear([x1, y2]) or @isClear([x2, y1])) 242 | ) 243 | 244 | isClear: ([x, y]) => 245 | offset = @grid.offset x, y 246 | if offset? # don't leave out 0! 247 | @points[offset][2] isnt 'blocked' 248 | 249 | start: => 250 | for [x, y, kind] in @points 251 | return [x, y] if kind is 'start' 252 | 253 | goal: => 254 | for [x, y, kind] in @points 255 | return [x, y] if kind is 'goal' 256 | 257 | draw: => 258 | squares = @grid.mapSelection.selectAll('rect') 259 | .data(@points, (d, i) -> [d[0], d[1]]) 260 | 261 | squares.enter() 262 | .append('rect') 263 | .attr('x', (d, i) => @grid.size * d[0]) 264 | .attr('y', (d, i) => @grid.size * d[1]) 265 | .attr('width', @grid.size) 266 | .attr('height', @grid.size) 267 | .on('mousedown', @mousedown) 268 | .on('touchstart', @mousedown) 269 | .on('mouseover', @mouseover) 270 | .on('touchmove', @mouseover) 271 | 272 | squares.attr('class', (d, i) -> d[2]) 273 | 274 | mousedown: (d, i) => 275 | return unless @editable 276 | square = d3.select(d3.event.target) 277 | @drag = square.attr 'class' 278 | @mouseover d, i 279 | d3.event.preventDefault() 280 | 281 | mouseup: => 282 | return unless @editable 283 | switch @drag 284 | when 'start' 285 | start = @grid.mapSelection.selectAll('rect.start') 286 | @updateNode start, 'start' 287 | when 'goal' 288 | goal = @grid.mapSelection.selectAll('rect.goal') 289 | @updateNode goal, 'goal' 290 | @drag = null 291 | 292 | mouseover: (d, i) => 293 | return unless @editable and @drag 294 | 295 | touches = d3.event.changedTouches 296 | coords = if touches? 297 | d3.touches(@grid.mapElement, touches)[0] 298 | else 299 | d3.mouse(@grid.mapElement) 300 | 301 | return unless coords = @grid.normalizedCoords coords 302 | 303 | # can't do d3.select(d3.event.target) since the target is static with 304 | # touchmove. 305 | square = @grid.mapSelection.selectAll('rect').filter (d, i) -> 306 | [x, y, _] = d 307 | x is coords[0] and y is coords[1] 308 | 309 | switch @drag 310 | when 'clear' 311 | if square.classed('clear') 312 | @updateNode square, 'blocked' 313 | when 'blocked' 314 | if square.classed('blocked') 315 | @updateNode square, 'clear' 316 | when 'start' 317 | if not square.classed('goal') 318 | before = @grid.mapSelection.selectAll('rect.start') 319 | before.classed('start', false) 320 | if before.attr('class') is "" 321 | @updateNode before, 'clear' 322 | square.classed('start', true) 323 | when 'goal' 324 | if not square.classed('start') 325 | before = @grid.mapSelection.selectAll('rect.goal') 326 | before.classed('goal', false) 327 | if before.attr('class') is "" 328 | @updateNode before, 'clear' 329 | square.classed('goal', true) 330 | 331 | d3.event.preventDefault() 332 | 333 | updateNode: (selection, type) => 334 | [x, y, _] = selection.datum() 335 | @updatePoint [x, y], type 336 | @draw() 337 | 338 | class Annotations 339 | constructor: (@grid) -> 340 | @defineArrowheads() 341 | 342 | update: (open, closed, paths, previous, @start, @goal, @current, forced, examined) => 343 | @open = open or [] 344 | @closed = closed or [] 345 | @paths = paths or [] 346 | @previous = previous or [] 347 | @forced = forced or [] 348 | @examined = examined or [] 349 | @draw() 350 | 351 | reset: => 352 | @open = @closed = @paths = @previous = @forced = @examined = [] 353 | @start = @goal = @current = null 354 | @draw() 355 | 356 | draw: => 357 | @drawSquares() 358 | @drawPaths() 359 | 360 | drawPaths: => 361 | data = [] 362 | data.push [pair, 'current'] for pair in @paths 363 | data.push [pair, 'previous'] for pair in @previous 364 | @drawPathSection '.paths', data 365 | 366 | data = ([pair, 'examined'] for pair in @examined) 367 | @drawPathSection '.extras', data 368 | 369 | drawPathSection: (container, data) => 370 | paths = @grid.annotationSelection.select(container).selectAll("line") 371 | .data(data, (d, i) -> JSON.stringify d) # don't reuse paths if type changes 372 | 373 | paths.enter() 374 | .append('line') 375 | .attr('x1', (d, i) => @lineSegment(d)[0]) # TODO this is calculated 4x? 376 | .attr('y1', (d, i) => @lineSegment(d)[1]) 377 | .attr('x2', (d, i) => @lineSegment(d)[2]) 378 | .attr('y2', (d, i) => @lineSegment(d)[3]) 379 | paths 380 | .attr('class', (d, i) -> d[1]) 381 | .attr('marker-end', (d, i) -> "url(#arrowhead-#{d[1]})") 382 | paths.exit().remove() 383 | 384 | drawSquares: => 385 | points = [] # first one wins if there's a duplicate 386 | points.push [@current[0], @current[1], 'current'] if @current 387 | points.push [@start[0], @start[1], 'start'] if @start 388 | points.push [@goal[0], @goal[1], 'goal'] if @goal 389 | points.push [x,y,'forced'] for [x, y] in @forced 390 | points.push [x,y,'open'] for [x, y] in @open 391 | points.push [x,y,'closed'] for [x, y] in @closed 392 | 393 | squares = @grid.annotationSelection.select('.squares').selectAll("rect") 394 | .data(points, (d, i) -> JSON.stringify d) 395 | squares.enter() 396 | .insert('rect', ':first-child') 397 | .attr('x', (d, i) => @grid.size * d[0]) 398 | .attr('y', (d, i) => @grid.size * d[1]) 399 | .attr('width', @grid.size) 400 | .attr('height', @grid.size) 401 | squares.attr('class', (d, i) -> d[2]) 402 | squares.exit().remove() 403 | 404 | # path goes from center of node to a little before the center of the next 405 | # returns [ x1, y1, x2, y2 ] 406 | lineSegment: (d) => 407 | [ [x1, y1], [x2, y2] ] = d[0] 408 | dx = x2 - x1 409 | dy = y2 - y1 410 | a = Math.sqrt((dx * dx) + (dy * dy)) 411 | x1 += 0.2 * if a is 0 then 0 else dx/a 412 | y1 += 0.2 * if a is 0 then 0 else dy/a 413 | x2 -= 0.2 * if a is 0 then 0 else dx/a 414 | y2 -= 0.2 * if a is 0 then 0 else dy/a 415 | 416 | [ x1 * @grid.size + @grid.size / 2, 417 | y1 * @grid.size + @grid.size / 2, 418 | x2 * @grid.size + @grid.size / 2, 419 | y2 * @grid.size + @grid.size / 2 ] 420 | 421 | defineArrowheads: => 422 | defs = @grid.annotationSelection.append('svg:defs') 423 | @defineArrowhead defs, 'current' 424 | @defineArrowhead defs, 'previous' 425 | @defineArrowhead defs, 'examined' 426 | 427 | defineArrowhead: (defs, kind) => 428 | defs 429 | .append('marker') 430 | .attr('id', "arrowhead-#{kind}") # TODO is this ok? 431 | .attr('orient', 'auto') 432 | .attr('viewBox', '0 0 10 10') 433 | .attr('refX', 6) 434 | .attr('refY', 5) 435 | .append('polyline') 436 | .attr('points', '0,0 10,5 0,10 1,5') 437 | 438 | class ImmediateNeighbors 439 | constructor: (@map) -> 440 | 441 | # return immediate neighbors of [x, y] on the map 442 | immediateNeighbors: (node) => 443 | ns = [] 444 | [x,y] = node.pos 445 | for dx in [-1..1] 446 | for dy in [-1..1] 447 | continue if dx is 0 and dy is 0 448 | p = [x + dx, y + dy] 449 | if @map.reachable [x,y], p 450 | ns.push new Node p 451 | ns 452 | 453 | successors: (node) => @immediateNeighbors node 454 | of: (node) => @successors node 455 | examined: (node) -> [] 456 | 457 | class JumpPointSuccessors extends ImmediateNeighbors 458 | # return jump-point successors of the given point on the map 459 | successors: (node) => 460 | ns = @neighbors node 461 | jumps = _.map ns, (n) => 462 | [px,py] = node.pos 463 | [x,y] = n.pos 464 | dx = x - px 465 | dy = y - py 466 | @jump node.pos, [dx, dy] 467 | jumps = _.filter jumps, (i) -> i? 468 | _.map jumps, (j) -> new Node j 469 | 470 | # return paths and nodes to which an iteration has examined indirectly 471 | examined: (node) => 472 | ns = @neighbors node 473 | jumps = _.map ns, (n) => 474 | [px,py] = node.pos 475 | [x,y] = n.pos 476 | dx = x - px 477 | dy = y - py 478 | [value, js] = @debugJump node.pos, [dx, dy] 479 | js 480 | jumps = _.flatten jumps, true 481 | 482 | jump: (from, direction) => 483 | [x, y] = from 484 | [dx, dy] = direction 485 | 486 | prev = from 487 | next = [x + dx, y + dy] 488 | while @map.reachable prev, next 489 | return next if _.isEqual next, @map.goal() 490 | return next if @forcedNeighbors(next, [dx, dy]).length 491 | return next if dx isnt 0 and dy isnt 0 and ( 492 | @jump(next, [dx, 0]) or @jump(next, [0, dy])) 493 | 494 | [nx, ny] = next 495 | prev = next 496 | next = [nx + dx, ny + dy] 497 | 498 | null 499 | 500 | # Jump in a direction and return all terminal nodes which were examined as 501 | # as part of the actual path finding itself. 502 | # 503 | # Returns an array of [return value, visited nodes] 504 | debugJump: (from, direction) => 505 | value = null 506 | nodes = [] 507 | 508 | [x, y] = from 509 | [dx, dy] = direction 510 | 511 | parent = new Node from 512 | 513 | prev = from 514 | next = [x + dx, y + dy] 515 | 516 | while @map.reachable prev, next 517 | if _.isEqual next, @map.goal() 518 | nodes.push new Node(next, parent) 519 | return [next, nodes] 520 | 521 | if @forcedNeighbors(next, [dx, dy]).length 522 | nodes.push new Node(next, parent) 523 | return [next, nodes] 524 | 525 | if dx isnt 0 and dy isnt 0 526 | [xValue, xNodes] = @debugJump next, [dx, 0] 527 | [yValue, yNodes] = @debugJump next, [0, dy] 528 | nodes = nodes.concat xNodes 529 | nodes = nodes.concat yNodes 530 | 531 | if xValue or yValue 532 | nodes.push new Node(next, parent) 533 | return [next, nodes] 534 | 535 | [nx, ny] = next 536 | prev = next 537 | next = [nx + dx, ny + dy] 538 | 539 | nodes.push new Node(prev, parent) unless prev is from 540 | [null, nodes] 541 | 542 | forcedNeighbors: (from, direction) => 543 | [x, y] = from 544 | [dx, dy] = direction 545 | 546 | forced = [] 547 | 548 | if dy is 0 549 | forced.push [x + dx, y - 1] unless @map.isClear [x, y - 1] 550 | forced.push [x + dx, y + 1] unless @map.isClear [x, y + 1] 551 | else if dx is 0 552 | forced.push [x - 1, y + dy] unless @map.isClear [x - 1, y] 553 | forced.push [x + 1, y + dy] unless @map.isClear [x + 1, y] 554 | else 555 | forced.push [x - dx, y + dy] unless @map.isClear [x - dx, y] 556 | forced.push [x + dx, y - dy] unless @map.isClear [x, y - dy] 557 | 558 | _.filter forced, (n) => @map.reachable from, n 559 | 560 | neighbors: (node) => 561 | if node.parent 562 | [x, y] = node.pos 563 | [px, py] = node.parent.pos 564 | 565 | dx = x - px 566 | dx = if dx > 1 then 1 else if dx < -1 then -1 else dx 567 | dy = y - py 568 | dy = if dy > 1 then 1 else if dy < -1 then -1 else dy 569 | 570 | neighbors = if dy is 0 # moving horizontally 571 | [[x + dx, y]] 572 | else if dx is 0 573 | [[x, y + dy]] 574 | else 575 | [ [x, y + dy], 576 | [x + dx, y], 577 | [x + dx, y + dy] ] 578 | reachable = _.filter neighbors, (n) => @map.reachable node.pos, n 579 | ns = _.union reachable, @forcedNeighbors [x, y], [dx, dy] 580 | _.map ns, (n) -> new Node n 581 | 582 | else # no parent, so expand in all directions 583 | @immediateNeighbors node 584 | 585 | class Node 586 | constructor: (@pos, @parent) -> 587 | @key = JSON.stringify @pos 588 | @g = @h = 0 589 | 590 | eq: (other) => 591 | @key is other.key 592 | 593 | class PathFinder 594 | constructor: (map, neighborStrategy=ImmediateNeighbors, @costStrategy=AStar) -> 595 | @open = {} 596 | @closed = {} 597 | @path = null 598 | 599 | @successors = new neighborStrategy map 600 | start = map.start() 601 | @start = new Node map.start() 602 | @goal = new Node map.goal() 603 | 604 | @start.g = 0 605 | @start.h = @chebyshev @start, @goal 606 | @open[@start.key] = @start 607 | @examined = [] 608 | 609 | distance: (from, to) -> 610 | [x1, y1] = from.pos 611 | [x2, y2] = to.pos 612 | # euclidean distance 613 | Math.sqrt( (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1) ) 614 | 615 | chebyshev: (from, to) -> 616 | [x1, y1] = from.pos 617 | [x2, y2] = to.pos 618 | dx = Math.abs x2 - x1 619 | dy = Math.abs y2 - y1 620 | # take cost of horizontal and vertical, then add cost of diagonal and 621 | # subtract the 2x savings by making that diagonal 622 | dx + dy + (Math.sqrt(2) - 2) * Math.min(dx, dy) 623 | 624 | # returns the current state for visualization 625 | state: => 626 | nodes = _.flatten([_.values(@open), _.values(@closed)]) 627 | withParents = _.select nodes, (e) -> e.parent? 628 | paths = _.map withParents, (e) -> [e.parent.pos, e.pos] 629 | examined = _.map @examined, (e) -> [e.parent.pos, e.pos] 630 | 631 | finalPath = [] 632 | if @path 633 | _.each @path, (e, i, l) => 634 | if next = l[i+1] 635 | finalPath.push [e, next] 636 | 637 | { 638 | open: _.pluck _.values(@open), "pos" 639 | closed: _.pluck _.values(@closed), "pos" 640 | current: !@path and @current and @current.pos 641 | paths: finalPath 642 | previous: paths 643 | examined: examined 644 | } 645 | 646 | # returns true if algorithm is complete 647 | step: => 648 | return true if @path 649 | @current = current = _.first _.sortBy _.values(@open), 650 | (n) => @costStrategy n.g, n.h 651 | 652 | return true unless current 653 | 654 | if current.eq @goal 655 | path = [@goal.pos] 656 | while current.parent? 657 | current = current.parent 658 | path.unshift current.pos 659 | @path = path 660 | return true 661 | 662 | delete @open[current.key] 663 | @closed[current.key] = current 664 | 665 | for neighbor in @successors.of current 666 | newG = current.g + @distance current, neighbor 667 | 668 | if existing = @open[neighbor.key] or existing = @closed[neighbor.key] 669 | continue if newG >= existing.g 670 | existing.parent = current 671 | existing.g = newG 672 | else 673 | neighbor.parent = current 674 | neighbor.g = newG 675 | neighbor.h = @chebyshev neighbor, @goal 676 | @open[neighbor.key] = neighbor 677 | 678 | @examined = _.uniq @examined.concat @successors.examined current 679 | 680 | null # not done yet 681 | 682 | Dijkstra = (g, h) -> g 683 | Greedy = (g, h) -> h 684 | AStar = (g, h) -> g + h 685 | 686 | log = (msgs...) -> console.log msgs... 687 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jps-explained", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "buffer-crc32": { 8 | "version": "0.2.13", 9 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", 10 | "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" 11 | }, 12 | "bytes": { 13 | "version": "0.2.0", 14 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-0.2.0.tgz", 15 | "integrity": "sha1-qtM+wU49wsp06OfUUfm6BTrU96A=" 16 | }, 17 | "coffee-script": { 18 | "version": "1.6.3", 19 | "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.3.tgz", 20 | "integrity": "sha1-Y1XTLPGwTN/2tITl5xF4Ky8MOb4=" 21 | }, 22 | "commander": { 23 | "version": "0.6.1", 24 | "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz", 25 | "integrity": "sha1-+mihT2qUXVTbvlDYzbMyDp47GgY=" 26 | }, 27 | "connect": { 28 | "version": "2.7.5", 29 | "resolved": "https://registry.npmjs.org/connect/-/connect-2.7.5.tgz", 30 | "integrity": "sha1-E5ERtLA/BTOlJJJ6iKZGrkZ7LAI=", 31 | "requires": { 32 | "buffer-crc32": "0.1.1", 33 | "bytes": "0.2.0", 34 | "cookie": "0.0.5", 35 | "cookie-signature": "1.0.0", 36 | "debug": "*", 37 | "formidable": "1.0.11", 38 | "fresh": "0.1.0", 39 | "pause": "0.0.1", 40 | "qs": "0.5.1", 41 | "send": "0.1.0" 42 | }, 43 | "dependencies": { 44 | "buffer-crc32": { 45 | "version": "0.1.1", 46 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.1.1.tgz", 47 | "integrity": "sha1-fhENyZU5CKt8MqzccMn5RbHLxSY=" 48 | } 49 | } 50 | }, 51 | "connect-coffee-script": { 52 | "version": "0.1.8", 53 | "resolved": "https://registry.npmjs.org/connect-coffee-script/-/connect-coffee-script-0.1.8.tgz", 54 | "integrity": "sha1-8Kb+G+D2KjyhpagL7zdI3M6dacM=", 55 | "requires": { 56 | "coffee-script": "*", 57 | "debug": "*", 58 | "mkdirp": "*" 59 | } 60 | }, 61 | "cookie": { 62 | "version": "0.0.5", 63 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.0.5.tgz", 64 | "integrity": "sha1-+az521frdWjJ/MWWJWt7si4wfIE=" 65 | }, 66 | "cookie-signature": { 67 | "version": "1.0.0", 68 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.0.tgz", 69 | "integrity": "sha1-AETzMqxiPfhRyRTojqzFfwyXBP4=" 70 | }, 71 | "debug": { 72 | "version": "4.1.1", 73 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", 74 | "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", 75 | "requires": { 76 | "ms": "^2.1.1" 77 | } 78 | }, 79 | "express": { 80 | "version": "3.1.2", 81 | "resolved": "https://registry.npmjs.org/express/-/express-3.1.2.tgz", 82 | "integrity": "sha1-UqAsjbjyK7+g10eNhHzUUWH5hfc=", 83 | "requires": { 84 | "buffer-crc32": "~0.2.1", 85 | "commander": "0.6.1", 86 | "connect": "2.7.5", 87 | "cookie": "0.0.5", 88 | "cookie-signature": "1.0.0", 89 | "debug": "*", 90 | "fresh": "0.1.0", 91 | "methods": "0.0.1", 92 | "mkdirp": "~0.3.4", 93 | "range-parser": "0.0.4", 94 | "send": "0.1.0" 95 | }, 96 | "dependencies": { 97 | "mkdirp": { 98 | "version": "0.3.5", 99 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz", 100 | "integrity": "sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=" 101 | } 102 | } 103 | }, 104 | "formidable": { 105 | "version": "1.0.11", 106 | "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.0.11.tgz", 107 | "integrity": "sha1-aPYzJaA15kS297s9ESQ7l2HeGzA=" 108 | }, 109 | "fresh": { 110 | "version": "0.1.0", 111 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.1.0.tgz", 112 | "integrity": "sha1-A+SwF4Qk5MLV0ZpU2IFM3JeTSFA=" 113 | }, 114 | "methods": { 115 | "version": "0.0.1", 116 | "resolved": "https://registry.npmjs.org/methods/-/methods-0.0.1.tgz", 117 | "integrity": "sha1-J3yQ+L7zlwlkWoNxxRw7bGSOBow=" 118 | }, 119 | "mime": { 120 | "version": "1.2.6", 121 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.6.tgz", 122 | "integrity": "sha1-sfhsdowCX6h7SAdfFwnyiuryA2U=" 123 | }, 124 | "minimist": { 125 | "version": "0.0.8", 126 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 127 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" 128 | }, 129 | "mkdirp": { 130 | "version": "0.5.1", 131 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 132 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 133 | "requires": { 134 | "minimist": "0.0.8" 135 | } 136 | }, 137 | "ms": { 138 | "version": "2.1.2", 139 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 140 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 141 | }, 142 | "pause": { 143 | "version": "0.0.1", 144 | "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", 145 | "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" 146 | }, 147 | "qs": { 148 | "version": "0.5.1", 149 | "resolved": "https://registry.npmjs.org/qs/-/qs-0.5.1.tgz", 150 | "integrity": "sha1-n2v12axsdjhOldNtFbSJgOXkrdA=" 151 | }, 152 | "range-parser": { 153 | "version": "0.0.4", 154 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-0.0.4.tgz", 155 | "integrity": "sha1-wEJ//vUcEKy6B4KkbJYC50T/Ygs=" 156 | }, 157 | "send": { 158 | "version": "0.1.0", 159 | "resolved": "https://registry.npmjs.org/send/-/send-0.1.0.tgz", 160 | "integrity": "sha1-z7COvTzsm3/Bo32f+eh1qXHPRkA=", 161 | "requires": { 162 | "debug": "*", 163 | "fresh": "0.1.0", 164 | "mime": "1.2.6", 165 | "range-parser": "0.0.4" 166 | } 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jps-explained", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "test": "./node_modules/.bin/nodeunit test" 6 | }, 7 | "dependencies": { 8 | "coffee-script": "~1.6.0", 9 | "express": "~3.1.0", 10 | "connect-coffee-script": "~0.1.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 || t |