flbulgarelli/headbreaker

View on GitHub
src/canvas.js

Summary

Maintainability
B
4 hrs
Test Coverage
const Pair = require('./pair');
const Piece = require('./piece');
const Puzzle = require('./puzzle');
const Manufacturer = require('../src/manufacturer');
const {twoAndTwo} = require('./sequence');
const Structure = require('./structure');
const ImageMetadata = require('./image-metadata');
const {vector, ...Vector} = require('./vector');
const Metadata = require('./metadata');
const SpatialMetadata = require('./spatial-metadata');
const {PuzzleValidator, PieceValidator} = require('./validator');
const {Horizontal, Vertical} = require('./axis');
const Shuffler = require('./shuffler');
const {diameter} = require('./size');
const {itself} = require('./prelude');
const {Classic} = require('./outline');

/**
 * @typedef {object} Shape
 * @typedef {object} Group
 * @typedef {object} Label
 */

/**
 * @typedef {object} Figure
 * @property {Shape} shape
 * @property {Group} group
 * @property {Label} [label]
 */

/**
 * @callback CanvasConnectionListener
 * @param {Piece} piece the connecting piece
 * @param {Figure} figure the visual representation of the connecting piece
 * @param {Piece} targetPiece the target connected piece
 * @param {Figure} targetFigure the visual representation of the target connected
 */

/**
 * @callback CanvasTranslationListener
 * @param {Piece} piece the translated piece
 * @param {Figure} figure the visual representation of the translated piece
 * @param {number} dx the horizontal displacement
 * @param {number} dy the vertical displacement
*/

/**
 * @typedef {object} LabelMetadata
 * @property {string} [text]
 * @property {number} [fontSize]
 * @property {number} [x]
 * @property {number} [y]
 */

/**
 * @typedef {object} CanvasMetadata
 * @property {string} [id]
 * @property {import('./vector').Vector} [targetPosition]
 * @property {import('./vector').Vector} [currentPosition]
 * @property {string} [color]
 * @property {boolean} [fixed]
 * @property {string} [strokeColor]
 * @property {import('./image-metadata').ImageLike} [image]
 * @property {LabelMetadata} [label]
 */

/**
 * @typedef {object} Template
 * @property {import('./structure').StructureLike} structure
 * @property {import('./size').Size} [size]
 * @property {CanvasMetadata} metadata
 */

 /**
  * An HTML graphical area where puzzles and pieces can be rendered. No assumption of the rendering backend is done - it may be
  * and be a plain HTML SVG or canvas element, or a higher-level library - and this task is fully delegated to {@link Painter}
  */
class Canvas {

  /**
   * @private
   * @typedef {import('./painter')} Painter
   */

  /**
   * @param {string} id  the html id of the element where to place the canvas
   * @param {object} options
   * @param {number} options.width
   * @param {number} options.height
   * @param {import('./vector').Vector|number} [options.pieceSize] the piece size expresed as it edge-to-edge diameter
   * @param {number} [options.proximity]
   * @param {import('./vector').Vector|number} [options.borderFill] the broder fill of the pieces, expresed in pixels. 0 means no border fill, 0.5 * pieceSize means full fill
   * @param {number} [options.strokeWidth]
   * @param {string} [options.strokeColor]
   * @param {number} [options.lineSoftness] how soft the line will be
   * @param {boolean} [options.preventOffstageDrag] whether dragging out of canvas is prevented
   * @param {import('./image-metadata').ImageLike} [options.image] an optional background image for the puzzle that will be split across all pieces.
   * @param {boolean} [options.fixed] whether the canvas can is fixed or can be dragged
   * @param {Painter} [options.painter] the Painter object used to actually draw figures in canvas
   * @param {import('./vector').Vector|number} [options.puzzleDiameter] the puzzle diameter used to calculate the maximal width and height
   *                                                                    You only need to specify this option when pieces are manually sketched and images must be adjusted
   * @param {import('./vector').Vector|number} [options.maxPiecesCount] the maximal amount of pieces used to calculate the maximal width and height.
   *                                                                    You only need to specify this option when pieces are manually sketched and images must be adjusted
   * @param {import('./outline').Outline} [options.outline]
   */
  constructor(id, {
      width,
      height,
      pieceSize = 50,
      proximity = 10,
      borderFill = 0,
      strokeWidth = 3,
      strokeColor = 'black',
      lineSoftness = 0,
      preventOffstageDrag = false,
      image = null,
      fixed = false,
      painter = null,
      puzzleDiameter = null,
      maxPiecesCount = null,
      outline = null
    }) {
    this.width = width;
    this.height = height;
    this.pieceSize = diameter(pieceSize);
    this.borderFill = Vector.cast(borderFill);
    this.imageMetadata = ImageMetadata.asImageMetadata(image);
    this.strokeWidth = strokeWidth;
    this.strokeColor = strokeColor;
    this.lineSoftness = lineSoftness;
    this.preventOffstageDrag = preventOffstageDrag;
    this.proximity = proximity;
    this.fixed = fixed;
    /** @type {Painter} */
    this._painter = painter || new window['headbreaker']['painters']['Konva']();
    this._initialize();
    this._painter.initialize(this, id);
    /** @type {import('./vector').Vector} */
    this._maxPiecesCount = Vector.cast(maxPiecesCount);
    /** @type {import('./vector').Vector} */
    this._puzzleDiameter = Vector.cast(puzzleDiameter);
    /** @type {(image: import('./image-metadata').ImageMetadata) => import('./image-metadata').ImageMetadata} */
    this._imageAdjuster = itself;
    this._outline = outline || Classic;
  }

  _initialize() {
    /** @type {Puzzle} */
    this._puzzle = null;
    /** @type {Object<string, Figure>} */
    this.figures = {};
    /** @type {Object<string, Template>} */
    this.templates = {};
    /** @type {import('./vector').Vector} */
    this._figurePadding = null;
    this._drawn = false;
  }

  /**
   * Creates and renders a piece using a template, that is ready to be rendered by calling {@link Canvas#draw}
   *
   * @param {Template} options
   */
  sketchPiece({structure, size = null, metadata}) {
    SpatialMetadata.initialize(metadata, Vector.zero())
    this.renderPiece(this._newPiece(structure, size, metadata));
  }

  /**
   * Renders a previously created piece object
   *
   * @param {Piece} piece
   */
  renderPiece(piece) {
    /** @type {Figure} */
    const figure = {label: null, group: null, shape: null};
    this.figures[piece.metadata.id] = figure;

    this._painter.sketch(this, piece, figure, this._outline);

    /** @type {LabelMetadata} */
    const label = piece.metadata.label;
    if (label && label.text) {
      label.fontSize = label.fontSize || piece.diameter.y * 0.55;
      label.y = label.y || (piece.diameter.y - label.fontSize) / 2;
      this._painter.label(this, piece, figure);
    }

    this._bindGroupToPiece(figure.group, piece);
    this._bindPieceToGroup(piece, figure.group);
  }

  /**
   * Renders many previously created piece objects
   *
   * @param {Piece[]} pieces
   */
  renderPieces(pieces) {
    pieces.forEach((it) => {
      this._annotatePiecePosition(it);
      this.renderPiece(it);
    });
  }

  /**
   * Renders a previously created puzzle object. This method
   * overrides this canvas' {@link Canvas#pieceDiameter} and {@link Canvas#proximity}
   *
   * @param {Puzzle} puzzle
   */
  renderPuzzle(puzzle) {
    this.pieceSize = puzzle.pieceSize;
    this.proximity = puzzle.proximity * 2;
    this._puzzle = puzzle;
    this.renderPieces(puzzle.pieces);
  }

  /**
   * Automatically creates and renders pieces given some configuration paramters
   *
   * @param {object} options
   * @param {number} [options.horizontalPiecesCount]
   * @param {number} [options.verticalPiecesCount]
   * @param {import('./sequence').InsertsGenerator} [options.insertsGenerator]
   * @param {CanvasMetadata[]} [options.metadata] optional list of metadata that will be attached to each generated piece
   */
  autogenerate({horizontalPiecesCount = 5, verticalPiecesCount = 5, insertsGenerator = twoAndTwo, metadata = []} = {}) {
    const manufacturer = new Manufacturer();
    manufacturer.withDimensions(horizontalPiecesCount, verticalPiecesCount);
    manufacturer.withInsertsGenerator(insertsGenerator);
    manufacturer.withMetadata(metadata);
    this.autogenerateWithManufacturer(manufacturer);
  }

  /**
   * @param {Manufacturer} manufacturer
   */
  autogenerateWithManufacturer(manufacturer) {
    manufacturer.withStructure(this.settings);
    this._puzzle = manufacturer.build();
    this._maxPiecesCount = vector(manufacturer.width, manufacturer.height);
    this.renderPieces(this.puzzle.pieces);
  }

  /**
   * Creates a name piece template, that can be later instantiated using {@link Canvas#sketchPieceUsingTemplate}
   *
   * @param {string} name
   * @param {Template} template
   */
  defineTemplate(name, template) {
    this.templates[name] = template;
  }

  /**
   * Creates a new Piece with given id using a named template
   * defined with {@link Canvas#defineTemplate}
   *
   * @param {string} id
   * @param {string} templateName
   */
  sketchPieceUsingTemplate(id, templateName) {
    const options = this.templates[templateName];
    if (!options) {
      throw new Error(`Unknown template ${id}`);
    }
    const metadata = Metadata.copy(options.metadata);
    metadata.id = id;
    this.sketchPiece({structure: options.structure, metadata: metadata})
  }

  /**
   * @param {number} farness from 0 to 1, how far pieces will be placed from x = pieceDiameter.x, y = pieceDiameter.y
   */
  shuffle(farness = 1) {
    const offset = this.pieceRadius;
    this.puzzle.shuffle(farness * (this.width - offset.x), farness * (this.height - offset.y))
    this.puzzle.translate(offset.x, offset.y);
    this.autoconnected = true;
  }

  /**
   * **Warning**: this method requires {@code maxPiecesCount} to be set.
   *
   * @param {number} farness
   */
  shuffleColumns(farness = 1) {
    this.shuffleWith(farness, Shuffler.columns);
  }

  /**
   * **Warning**: this method requires {@code maxPiecesCount} to be set.
   *
   * @param {number} farness
   */
  shuffleGrid(farness = 1) {
    this.shuffleWith(farness, Shuffler.grid);
  }

  /**
   * **Warning**: this method requires {@code maxPiecesCount} to be set.
   * **Warning**: this method requires puzzle to have an even number of columns
   *
   * @param {number} farness
   */
  shuffleLine(farness = 1) {
    this.shuffleWith(farness, Shuffler.line);
  }


  /**
   * @param {number} farness
   * @param {import('./shuffler').Shuffler} shuffler
   */
  shuffleWith(farness, shuffler) {
    this.solve();
    this.puzzle.shuffleWith(Shuffler.padder(this.proximity * 3, this.maxPiecesCount.x, this.maxPiecesCount.y));
    this.puzzle.shuffleWith(shuffler)
    this.puzzle.shuffleWith(Shuffler.noise(Vector.cast(this.proximity * farness / 2)))
    this.autoconnected = true;
  }

  solve() {
    this.puzzle.pieces.forEach(it => {
      const {x, y} = it.metadata.targetPosition;
      it.relocateTo(x, y);
    });
    this.autoconnect();
  }

  autoconnect() {
    this.puzzle.autoconnect();
    this.autoconnected = true;
  }

  /**
   * Registers keyboard gestures. `gestures` must be an object with one or more entries with the following format:
   *
   * ```
   * { keyStrokeNumber: (puzzle) => ...change drag mode... }
   * ```
   *
   * For example, if you want to configure your canvas to force pieces to be moved together using the `alt` key, you can do the following:
   *
   * ```
   * canvas.registerKeyboardGestures({ 18: (puzzle) => puzzle.forceConnectionWhileDragging() })
   * ```
   *
   * If no gestures are given, then the following gestures are configured:
   *
   * - `16` (`shift`): drags blocks of pieces as a whole, regardless of the movement direction
   * - `17` (`ctrl`): drags pieces individually, regardless of the movement direction
   *
   *
   * @param {object} gestures
   */
  registerKeyboardGestures(gestures = {
    16: (puzzle) => puzzle.forceConnectionWhileDragging(),
    17: (puzzle) => puzzle.forceDisconnectionWhileDragging()
  }) {
    this._painter.registerKeyboardGestures(this, gestures);
  }

  /**
   * Draws this canvas for the first time
   */
  draw() {
    if (this._drawn) {
      throw new Error("This canvas has already been drawn. Call redraw instead");
    }

    if (!this.autoconnected) {
      this.autoconnect();
    }
    this.puzzle.updateValidity();
    this.autoconnected = false;
    this.redraw();

    this._drawn = true;
  }

  /**
   * Re-draws this canvas. This method is useful when the canvas {@link Figure}s have
   * being modified and you need changes to become visible
   */
  redraw() {
    this._painter.draw(this);
  }

  /**
   * Refreshes image metadata.
   *
   * Use this method in order adjuster updates and image changes after initial draw
   * to make effect.
   **/
  refill() {
    this.puzzle.pieces.forEach(piece => {
      this._painter.fill(this, piece, this.getFigure(piece));
    });
  }

  /**
   * Clears the canvas, clearing the rendering backend and discarding all the created templates, figures, and pieces
   */
  clear() {
    this._initialize();
    this._painter.reinitialize(this);
  }

  /**
   * Attaches a connection requirement function that will be used to check whether
   * two close and matching pieces can be actually connected. By default no connection
   * requirement is imposed which means that any close and matching pieces will be
   * connected.
   *
   * @param {import('./connector').ConnectionRequirement} requirement
   */
  attachConnectionRequirement(requirement) {
    this.puzzle.attachConnectionRequirement(requirement);
  }

  /**
   * Removes the connection requirement, if any. 
   */
  clearConnectionRequirements() {
    this.puzzle.clearConnectionRequirements();
  }

  /**
   * Sets a validator for the canvas' puzzle. Only one validator
   * can be attached, so subsequent calls of this method will override the previously
   * attached validator
   *
   * @param {import('./validator').Validator} validator
   */
  attachValidator(validator) {
    this.puzzle.attachValidator(validator);
  }

  /**
   * Sets a validator that will report when puzzle has been solved,
   * overriding any previously configured validator
   */
  attachSolvedValidator() {
    this.puzzle.attachValidator(new PuzzleValidator(SpatialMetadata.solved));
  }

  /**
   * Sets a validator that will report when puzzle pieces are in their expected relative
   * positions, overriding any previously configured validator
   */
  attachRelativePositionValidator() {
    this.puzzle.attachValidator(new PuzzleValidator(SpatialMetadata.relativePosition));
  }

  /**
   * Sets a validator that will report when puzzle are at the expected given
   * relative refs
   *
   * @param {[number, number][]} expected
   */
  attachRelativeRefsValidator(expected) {
    this.puzzle.attachValidator(new PuzzleValidator(PuzzleValidator.relativeRefs(expected)));
  }

  /**
   * Sets a validator that will report when puzzle pieces are in their expected absolute
   * positions, overriding any previously configured validator
   */
  attachAbsolutePositionValidator() {
    this.puzzle.attachValidator(new PieceValidator(SpatialMetadata.absolutePosition));
  }

  /**
   * Registers a listener for connect events
   *
   * @param {CanvasConnectionListener} f
   */
  onConnect(f) {
    this.puzzle.onConnect((piece, target) => {
      f(piece, this.getFigure(piece), target, this.getFigure(target));
    });
  }

  /**
   * Registers a listener for disconnect events
   *
   * @param {CanvasConnectionListener} f
   */
  onDisconnect(f) {
    this.puzzle.onDisconnect((piece, target) => {
      f(piece, this.getFigure(piece), target, this.getFigure(target));
    });
  }

  /**
   * @param {CanvasTranslationListener} f
   */
  onTranslate(f) {
    this.puzzle.onTranslate((piece, dx, dy) => {
      f(piece, this.getFigure(piece), dx, dy);
    });
  }

  /**
   * Translates all the pieces - preserving their relative positions - so that
   * they all can be visible, if possible. If they are already fully visible,
   * this method does nothing.
   *
   * In order to prevent unexpected translations, this method will fail
   * if canvas is not `fixed`.
   */
  reframeWithinDimensions() {
    if (!this.fixed) throw new Error("Only fixed canvas can be reframed")

    this.puzzle.reframe(
      this.figurePadding,
      Vector.minus(vector(this.width, this.height), this.figurePadding));
  }

  /**
   * @param {import('./validator').ValidationListener} f
   */
  onValid(f) {
    this.puzzle.onValid(f);
  }

  /**
   * Returns the current validation status
   *
   * @type {boolean}
   */
  get valid() {
    return this.puzzle.valid;
  }

  /**
   * Answers the visual representation for the given piece.
   * This method uses piece's id.
   *
   * @param {Piece} piece
   * @returns {Figure}
   */
  getFigure(piece) {
    return this.getFigureById(piece.metadata.id);
  }

  /**
   * Answers the visual representation for the given piece id.
   *
   * @param {string} id
   * @returns {Figure}
   */
  getFigureById(id) {
    return this.figures[id];
  }

  /**
   * Sets the new width and height of the canvas
   *
   * @param {number} width
   * @param {number} height
   */
  resize(width, height) {
    this.width = width;
    this.height = height;
    this._painter.resize(this, width, height);
  }

  /**
   * Scales the canvas contents to the given factor
   * @param {import('./vector').Vector|number} factor
   */
  scale(factor) {
    this._painter.scale(this, Vector.cast(factor));
  }

  /**
   * @param {Piece} piece
   */
  _annotatePiecePosition(piece) {
    const p = piece.centralAnchor.asVector();
    SpatialMetadata.initialize(piece.metadata, p, Vector.copy(p));
  }

  /**
   * Configures updates from piece into group
   * @param {Group} group
   * @param {Piece} piece
   */
  _bindGroupToPiece(group, piece) {
    piece.onTranslate((_dx, _dy) => {
      this._painter.physicalTranslate(this, group, piece);
      this._painter.logicalTranslate(this, piece, group);
    });
  }

  /**
   * * Configures updates from group into piece
   * @param {Piece} piece
   * @param {Group} group
   */
  _bindPieceToGroup(piece, group) {
    this._painter.onDrag(this, piece, group, (dx, dy) => {
      if (!Pair.isNull(dx, dy)) {
        piece.drag(dx, dy, true);
        this._painter.logicalTranslate(this, piece, group);
        this.redraw();
      }
    });
    this._painter.onDragEnd(this, piece, group, () => {
      piece.drop();
      this.puzzle.validate();
      this.redraw();
    })
  }

  /**
   * @param {Piece} piece
   * @returns {import('./image-metadata').ImageMetadata}
   */
  _baseImageMetadataFor(piece) {
    if (this.imageMetadata) {
      const scale = piece.metadata.scale || this.imageMetadata.scale || 1;
      const offset = Vector.plus(
        piece.metadata.targetPosition || Vector.zero(),
        this.imageMetadata.offset || Vector.zero());
      return { content: this.imageMetadata.content, offset, scale };
    } else {
      return ImageMetadata.asImageMetadata(piece.metadata.image);
    }
  }

  /**
   * @param {Piece} piece
   *
   * @returns {import('./image-metadata').ImageMetadata}
   */
  imageMetadataFor(piece) {
    return this._imageAdjuster(this._baseImageMetadataFor(piece));
  }

  /**
   * Configures canvas to adjust images axis to puzzle's axis.
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   *
   * @param {import('./axis').Axis} axis
   */
  adjustImagesToPuzzle(axis) {
    this._imageAdjuster = (image) => {
      const scale = axis.atVector(this.puzzleDiameter) / axis.atDimension(image.content);
      const offset = Vector.plus(image.offset, Vector.minus(this.borderFill, this.pieceDiameter));
      return { content: image.content, scale, offset };
    };
  }

  /**
   * Configures canvas to adjust images width to puzzle's width
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   */
  adjustImagesToPuzzleWidth() {
    this.adjustImagesToPuzzle(Horizontal);
  }

  /**
   * Configures canvas to adjust images height to puzzle's height
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   */
  adjustImagesToPuzzleHeight() {
    this.adjustImagesToPuzzle(Vertical);
  }

  /**
   * Configures canvas to adjust images axis to pieces's axis
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   *
   * @param {import('./axis').Axis} axis
   */
  adjustImagesToPiece(axis) {
    this._imageAdjuster = (image) => {
      const scale = axis.atVector(this.pieceDiameter) / axis.atDimension(image.content);
      const offset = Vector.plus(image.offset, this.borderFill);
      return { content: image.content, scale, offset };
    }
  }

  /**
   * Configures canvas to adjust images width to pieces's width
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   */
  adjustImagesToPieceWidth() {
    this.adjustImagesToPiece(Horizontal);
  }

  /**
   * Configures canvas to adjust images height to pieces's height
   *
   * **Warning**: this method requires {@code maxPiecesCount} or {@code puzzleDiameter} to be set.
   */
  adjustImagesToPieceHeight() {
    this.adjustImagesToPiece(Vertical);
  }


  _initializeEmptyPuzzle() {
    this._puzzle = new Puzzle(this.settings);
  }

  /**
   * @param {import('./structure').StructureLike} structureLike the piece structure
   * @param {import('./size').Size} size
   * @param {CanvasMetadata} metadata
   */
  _newPiece(structureLike, size, metadata) {
    let piece = this.puzzle.newPiece(
      Structure.asStructure(structureLike),
      { centralAnchor: vector(metadata.currentPosition.x, metadata.currentPosition.y), metadata, size });
    return piece;
  }

  /**
   * The puzzle diameter, using the
   * configured puzzle diameter or the estimated one, if the first is not available.
   *
   * @type {import('./vector').Vector}
   * */
  get puzzleDiameter() {
    return this._puzzleDiameter || this.estimatedPuzzleDiameter;
  }

  /**
   * The estimated puzzle diameter calculated using the the max pieces count.
   *
   * @type {import('./vector').Vector}
   * */
  get estimatedPuzzleDiameter() {
    return Vector.plus(Vector.multiply(this.pieceDiameter, this.maxPiecesCount), this.strokeWidth * 2)
  }

  get maxPiecesCount() {
    if (!this._maxPiecesCount) {
      throw new Error("max pieces count was not specified");
    }
    return this._maxPiecesCount;
  }

  /**
   * @type {import('./vector').Vector}
   */
  get pieceRadius() {
    return this.pieceSize.radius;
  }

  /**
   * @type {import('./vector').Vector}
   */
  get pieceDiameter() {
    return this.pieceSize.diameter;
  }

  /**
   * @type {import('./vector').Vector}
   */
  get figurePadding() {
    if (!this._figurePadding) {
      this._figurePadding = Vector.plus(this.strokeWidth, this.borderFill);
    }
    return this._figurePadding;
  }

  /**
   * @type {Number}
   **/
  get figuresCount() {
    return Object.values(this.figures).length;
  }

  /**
   * The puzzle rendered by this canvas
   *
   * @type {Puzzle}
   */
  get puzzle() {
    if (!this._puzzle) {
      this._initializeEmptyPuzzle();
    }
    return this._puzzle;
  }

  /**
   * @type {import('./puzzle').Settings}
   */
  get settings() {
    return {pieceRadius: this.pieceRadius, proximity: this.proximity}
  }
}

module.exports = Canvas