legacy/outils/droitegraduee/droitegraduee.js

import { j3pAddElt, j3pAjouteBouton, j3pClone, j3pElement, j3pEmpty, j3pGetMousePositionInElt, j3pGetNewId, j3pParseFrac, j3pVirgule } from 'src/legacy/core/functions'
import { hasProp } from 'src/lib/utils/object'
import { j3pCreeRectangle, j3pCreeSegment, j3pCreeSVG, j3pCreeTexte } from 'src/legacy/core/functionsSvg'
/*
 * Outil Droite graduée
 * JP Vanroyen - Juillet 2012
 * Réécrit par DC en décembre 2020
 *
 * 2020-12-15 rev13623 dernière version avec reconstruction du svg au mousemove
 * 2020-12-21 rev13659 dernière version avec le zoom continu via glissement (on passe à la version curseur)
 *
 * On continue à gérer les déplacement avec du mousedown (on démarre le déplacement)
 * puis mousemove (ça déplace qqchose) puis mouseup (on arrête), car l’api drag&drop
 * https://developer.mozilla.org/fr/docs/Web/API/API_HTML_Drag_and_Drop
 * est pas encore très bien implémentée sur mobile, et par ailleurs ça changerait pas grand chose.
 *
 * On essaie en revanche de reconstruire le svg le moins souvent possible.
 *
 * @todo ajouter un constructeur Point pour contrôler ce qu’on passe et initialiser des valeurs par défaut
 * @todo Ajouter un segment unité (de la longueur d’une graduation principale, avec sa taille indiquée dessous)
 */

// cf https://developer.mozilla.org/fr/docs/Web/CSS/cursor#Syntaxe pour les valeurs possibles de curseur

// valeurs de this.enCours
// (pour avoir l’autocompletion dessus et éviter les fautes de frappes)
const zoomIn = 'zoomIn' // on a cliqué sur le bouton zoomIn
const zoomOut = 'zoomOut' // on a cliqué sur le bouton zoomOut
const placerPoint = 'placerPoint' // on a cliqué sur le bouton placerPoint
const deplacementAxe = 'deplacementAxe' // déplacement de l’axe
const deplacementPoint = 'deplacementPoint' // déplacement d’un point
// autres constantes
/**
 * marge entre le conteneur SVG de la droite graduée et l’axe
 * @private
 */
const marge = 20
// options à passer à chaque addEventListener
// cf https://www.quirksmode.org/js/events_order.html pour capture / bubbling
const listenerOptions = {
  capture: false,
  passive: true
}

export default Droitegraduee

/**
 * Constructeur de droite graduée
 * @param {string|HTMLElement} conteneur
 * @param {object} options Les valeurs optionnelles
 * @param {number} [options.width] si absent on s’adapte à l’espace dispo
 * @param {number} [options.height=200]
 * @param {boolean} [options.etiquettes=true] passer false pour ne pas afficher les abscisses des graduations
 * @param {string[]} [options.listeetiquettes] pour remplacer les étiquettes de graduation principale (si etiquettes = false, sinon c’est ignoré)
 * @param {string} [options.couleuraxe=#000000] noir par défaut
 * @param {string} [options.couleurgraduationprincipale=#000000] Idem axe par défaut
 * @param {string} [options.couleurgraduation] Idem principale par défaut
 * @param {string} [options.couleurgraduationpetite] Idem couleurgraduation par défaut
 * @param {number} [options.maxZoomDecimal=3] Nb d’échelles de zoom autorisés (en + ou en -, valable aussi pour le zoom continu)
 * @param {number} [options.positionO] décalage de l’origine par rapport au milieu, en pixels, par défaut on le place au milieu si les négatifs sont affichés et à gauche sinon (avec un peu de marge)
 * @param {number} [options.pixelsParGraduation=100]
 * @param {number} [options.pas=1] Unités d’abscisse entre deux graduations principales
 * @param {number} [options.pixelsminpourgraduations=100] Si graduationsecondaires alors on les affiche lorsque l’espacement entre graduations principales est au-dessus de cette valeur
 * @param {number} [options.tailleetiquetteprincipale=14]
 * @param {number} [options.tailleetiquette=10]
 * @param {number} [options.taillesegmentprincipal=14]
 * @param {number} [options.taillesegment=10]
 * @param {number} [options.taillesegmentpetit=6]
 * @param {number} [options.epaisseurgraduationprincipale=2]
 * @param {number} [options.epaisseurgraduation=1]
 * @param {number} [options.epaisseuraxe=2]
 * @param {boolean} [options.zoomable=false] passer true pour ajouter le zoom continu
 * @param {number} [options.zoomCenter] abscisse du centre du zoom continu (si non fourni c’est le milieu du segment affiché)
 * @param {boolean} [options.deplacable=false] passer true pour autoriser l’utilisateur à faire glisser l’axe
 * @param {boolean} [options.negatifs=true] passer true pour tracer les négatifs
 * @param {boolean} [options.graduationsecondaires=true]
 * @param {boolean} [options.hasZoomDecimal=false]
 * @param {boolean} [options.boutonplacerpoint=false]
 * @param {object} [options.pointconstruit] Les caractéristiques du point qui serait créé au clic sur le bouton "point"
 * @param {object} [options.maxNbPoints=1] Le nb max de points (fournis ou à construire)
 * @param {object[]} [options.listePoints] Une liste de points à placer sur la droite
 * @constructor
 */
function Droitegraduee (conteneur, options) {
  if (typeof this !== 'object') throw Error('Constructeur qui doit être appelé avec new')
  // Le conteneur est une zone de j3p
  // Il contiendra un div positionné, qui contiendra le svg
  if (typeof conteneur === 'string') conteneur = j3pElement(conteneur)
  const dg = this // pour les cb
  // le div wrapper de svg + infos
  let wrapper = j3pAddElt(conteneur, 'div', '', { style: { margin: '1rem', padding: 0, width: '100%' } })
  const defaultWidth = wrapper.offsetWidth

  // on regarde s’il faut ajouter des contrôles, boutons ou zoom continu
  let hasZoomContinu = Boolean(options.zoomable) // zoom continu
  this.hasAxeMobile = Boolean(options.deplacable) // translation de l’axe à la souris
  this.maxZoomDecimal = Math.round(Math.abs(options.maxZoomDecimal)) || 3
  let hasZoomDecimal = options.hasZoomDecimal && this.maxZoomDecimal > 0

  // cast en booléen avec true par défaut
  this.negatifs = !(options.negatifs === false)
  this.etiquettes = !(options.etiquettes === false)

  /** Le nb de zoom in|out faits depuis le début */
  this.currentZoomLevel = 0

  if (Array.isArray(options.listeetiquettes) && options.listeetiquettes.length) {
    this.listeEtiquettes = options.listeetiquettes
    // on bloque alors le zoom (pas de sens de changer d’échelle de graduation avec les même étiquettes)
    this.maxZoomDecimal = 0
    hasZoomContinu = false
    hasZoomDecimal = false
  }

  const hasButtons = hasZoomDecimal || options.boutonplacerpoint

  // wrapper.offsetWidth est trop petit à ce stade, je sais pas pourquoi…
  this.width = Number(options.width) || defaultWidth - (hasButtons ? 90 : 30)
  if (this.width < 200) this.width = 200
  this.height = Number(options.height) || 200
  /** la demi-hauteur du svg, qu’on utilise partout */
  this.yMid = this.height / 2
  /** la demie longueur */
  this.xMid = this.width / 2
  /** nb de pixels entre deux graduations principales */
  this.pixelsParGraduation = Number(options.pixelsParGraduation) || 100
  // pour le reset à chaque changement de pas
  if (hasZoomContinu) this.pixelsParGraduationOrig = this.pixelsParGraduation
  if (options.zoomCenter) {
    this.zoomCenter = Number(options.zoomCenter)
    if (isNaN(this.zoomCenter)) console.error(Error('option zoomCenter invalide'), options.zoomCenter)
  }

  /** nb d’unité d’abscisse entre deux graduations principales */
  this.pas = Number(options.pas) || Number(options.pasdunegraduation) || 1
  // déduits du reste
  this.pixelsParUnite = this.pixelsParGraduation / this.pas
  let xOrigine
  if (hasProp(options, 'positionO')) {
    // positionO est la position de O en pixels par rapport au centre du svg
    xOrigine = Number(options.positionO) + this.xMid
  }
  if (isNaN(xOrigine)) {
    if (this.negatifs) {
      xOrigine = this.xMid // au milieu
    } else {
      xOrigine = marge // à gauche, mais pas collé au bord
    }
  }

  /**
   * abscisse du bord gauche du svg
   * @type {number}
   */
  this.absStart = -1 * xOrigine / this.pixelsParUnite

  if (hasButtons) {
    // on ajoute un div pour les boutons
    const divBtn = j3pAddElt(wrapper, 'div', '', { style: { width: '60px', margin: marge + 'px 0.5em', display: 'inline-block', verticalAlign: 'top' } })
    // et un nouveau wrapper pour le svg
    /** {HTMLElement} le div contenant les boutons et le svg */
    this.btnWrapper = wrapper // dans l’objet pour pouvoir changer le curseur dans les fcts
    wrapper = j3pAddElt(this.btnWrapper, 'div', '', { style: { display: 'inline-block', verticalAlign: 'top' } })
    // les boutons
    if (hasZoomDecimal) {
      this.btnZoomIn = j3pAjouteBouton(divBtn, onClickBtnZoom.bind(this, zoomIn), { className: 'MepBoutonsDG2plus' })
      this.btnZoomOut = j3pAjouteBouton(divBtn, onClickBtnZoom.bind(this, zoomOut), { className: 'MepBoutonsDG2moins' })
      this.btnZoomOut.style.marginTop = '1em'
    }
    if (options.boutonplacerpoint) {
      this.btnPlacer = j3pAjouteBouton(divBtn, onClickBtnPlacer.bind(this), { className: 'MepBoutonsDG2ajout', title: 'Placer un point' })
      if (this.btnZoomOut) {
        this.btnPlacer.style.marginTop = '1em'
      }
    }
  }

  // faut ajouter un div pour le feedback, mais après l’éventuel zoom continu
  /** le div de notre svg, support des listeners (car non détruit / recréé à chaque fois) */
  this.div = j3pAddElt(wrapper, 'div')

  // un div pour le zoom continu ?
  if (hasZoomContinu) {
    // on ajoute un slider
    const divSlider = j3pAddElt(wrapper, 'div', '', { style: { display: 'flex', margin: '0.4em 0' } })
    j3pAddElt(divSlider, 'span', 'Zoom : ', { style: { flex: '0 0 auto' } })
    const slider = j3pAddElt(divSlider, 'input', '', {
      type: 'range',
      value: 0,
      min: -this.maxZoomDecimal,
      max: this.maxZoomDecimal,
      step: this.maxZoomDecimal / 100,
      style: {
        minWidth: '200px',
        flex: '1 1 auto'
      }
    })
    // const reste = divSlider.offsetWidth - label.offsetWidth - 5
    // console.log('label prend', label.offsetWidth, 'de', wrapper.offsetWidth, 'reste', reste, label.style.width)
    slider.style.width = '100%'
    const pasOriginal = this.pas
    // console.log('démarrage avec zoomcenter', dg.zoomCenter, 'avec pas', dg.pas, 'et pix/u', dg.pixelsParUnite, 'scale', dg.scale, 'absStart', dg.absStart, 'pixelsParGraduationOrig', dg.pixelsParGraduationOrig)
    // faut écouter input si on veut être appelé en continu (sinon change appelé lorsqu’on lâche le curseur)
    slider.addEventListener('input', function () {
      // on cherche d’abord un centre de zoom
      let zoomCenter = dg.zoomCenter
      if (!zoomCenter || zoomCenter < dg.absStart || (zoomCenter - dg.absStart) * dg.pixelsParUnite > dg.width) {
        // pas de zoomCenter ou zoomCenter est sorti du svg, on prend le milieu
        zoomCenter = dg.absStart + dg.xMid / dg.pixelsParUnite
      }
      const xZoomCenter = (zoomCenter - dg.absStart) * dg.pixelsParUnite
      // maintenant on regarde les nouvelles valeurs d’après le slider
      const zoomFactor = Number(slider.value)
      dg.currentZoomLevel = Math.trunc(zoomFactor)
      const scaleFrac = zoomFactor - dg.currentZoomLevel // entre -1 et 1
      const scale = Math.pow(10, scaleFrac) // entre 0.1 et 10
      dg.pixelsParGraduation = dg.pixelsParGraduationOrig * scale
      // on ajuste le pas ?
      const min = dg.pixelsParGraduationOrig / 2
      if (dg.pixelsParGraduation < min) {
        dg.currentZoomLevel--
        dg.pixelsParGraduation *= 10
      } else if (dg.pixelsParGraduation > 10 * min) {
        dg.currentZoomLevel++
        dg.pixelsParGraduation /= 10
      }
      dg.pas = pasOriginal * Math.pow(10, -dg.currentZoomLevel)
      const newPixelsParUnite = dg.pixelsParGraduation / dg.pas
      dg.absStart = zoomCenter - xZoomCenter / newPixelsParUnite
      dg.scale = 1
      dg.svg.setAttribute('viewBox', '0 0 ' + dg.width + ' ' + dg.height)
      dg.construire()
    })
  }
  this.infos = j3pAddElt(wrapper, 'div', '', { style: { margin: 0, padding: '0.5em 1em' } })

  // le svg sera ajouté par construire)
  this.svg = null
  // on ajoute un cadre autour du svg
  this.div.style.border = '#000 2px solid'
  // et on fixe la taille du div contenant le svg
  wrapper.style.width = (this.width + 4) + 'px'
  this.div.style.height = this.height + 'px'
  this.div.style.padding = 0
  this.div.style.margin = 0

  /** {boolean} pour afficher les sous-graduations (et éventuelles sous-sous-graduations) */
  this.graduationsecondaires = !(options.graduationsecondaires === false)

  /** seuil d’affichage des sous-graduations */
  this.pixelsminpourgraduations = Number(options.pixelsminpourgraduations) || 100

  /** taille police etiquette graduation principale */
  this.tailleetiquetteprincipale = Number(options.tailleetiquetteprincipale) || 14

  /** taille police etiquette sous-graduation */
  this.tailleetiquette = Number(options.tailleetiquette) || 10

  /** taille graduation principale */
  this.taillesegmentprincipal = Number(options.taillesegmentprincipal) || 14

  /** taille sous-gradution */
  this.taillesegment = Number(options.taillesegment) || 10

  /** taille sous-sous-graduation */
  this.taillesegmentpetit = Number(options.taillesegmentpetit) || 6

  // propriétés de l’axe
  this.couleuraxe = options.couleuraxe || 'black'
  this.epaisseuraxe = Number(options.epaisseuraxe) || 2
  // les couleurs des graduations, et des polices correspondantes
  this.couleurgraduationprincipale = options.couleurgraduationprincipale || this.couleuraxe
  this.couleurgraduation = options.couleurgraduation || this.couleurgraduationprincipale
  this.couleurgraduationpetite = options.couleurgraduationpetite || this.couleurgraduation
  // épaisseurs des graduations
  this.epaisseurgraduationprincipale = Number(options.epaisseurgraduationprincipale) || 2
  this.epaisseurgraduation = Number(options.epaisseurgraduation) || 1
  // les points
  this.maxNbPoints = Number(options.maxNbPoints) || 1
  let hasMobilePoint = false
  this.points = {}
  let nbPoints = 0
  if (Array.isArray(options.listePoints)) {
    options.listePoints.forEach(function (point) {
      dg.points[point.nom] = point
      if (point.fixe === false) hasMobilePoint = true
      nbPoints++
    })
  }
  this.maxNbPoints = Number(options.maxNbPoints) || nbPoints
  if (this.maxNbPoints < nbPoints) {
    console.error(Error('maxNbPoints invalide, fixé à ' + nbPoints))
    this.maxNbPoints = nbPoints
  }

  // Le point qui sera créé au clic sur le bouton construire un point
  this.defaultPoint = options.pointconstruit || {
    nom: 'M',
    abscisse: '',
    nbdecimales: 3,
    etiquette: false,
    taillepoint: 10,
    taillepolice: 18,
    police: 'times new roman',
    couleur: 'red',
    epaisseur: 2,
    fixe: false
  }
  if (!this.defaultPoint.fixe) hasMobilePoint = true

  // faut écouter la souris sur le svg ?
  if (this.hasAxeMobile || hasMobilePoint) {
    const listener = onMouseDown.bind(this)
    this.div.addEventListener('mousedown', listener, listenerOptions)
    this.div.addEventListener('touchstart', listener, listenerOptions)
  }
  if (hasButtons) {
    this.div.addEventListener('click', onClick.bind(this), listenerOptions)
  }

  this.construire()
} // constructor

/**
   * Reconstruit complètement this.svg, à partir de absStart, pixelsParGraduation, pas et scale
   */
Droitegraduee.prototype.construire = function () {
  // console.log('construire avec une abscisse de départ de', this.absStart, 'un pas de', this.pas, 'et une largeur du pas de', this.pixelsParGraduation, 'pixels')
  if (this.isRebuilding) return console.error(Error('reconstruction déjà en cours'))
  this.isRebuilding = true
  this.cadreZoom = null
  this.pixelsParUnite = this.pixelsParGraduation / this.pas
  // on ramène à un scale 1
  if (typeof this.scale !== 'number') this.scale = 1
  if (this.scale !== 1) {
    this.pixelsParGraduation *= this.scale
    // on décale le départ pour centrer le zoom
    // absStart2 = absStart1 + x / pixelsParUnite1 - x / pixelsParUnite2
    const newPixelsParUnite = this.pixelsParGraduation / this.pas
    const xZoomCenter = isNaN(this.zoomCenter) ? this.xMid : (this.zoomCenter - this.absStart) * this.pixelsParUnite
    this.absStart += xZoomCenter / this.pixelsParUnite - xZoomCenter / newPixelsParUnite
    this.pixelsParUnite = newPixelsParUnite
    this.scale = 1
  } else if (this.svg) {
    // faut récupérer un éventuel décalage en x sans scale (glissement de l’axe à la souris)
    const vb = this.svg.getAttribute('viewBox').split(' ') // [x, y, w, h]
    if (vb[0] !== '0') {
      this.absStart += Number(vb[0]) / this.pixelsParUnite
    }
  }
  j3pEmpty(this.div)
  // Creation de la zone SVG dans ce DIV conteneur. Ce SVG est la droite graduee
  const svgProps = {
    width: this.width,
    height: this.height,
    viewBox: '0 0 ' + this.width + ' ' + this.height,
    preserveAspectRatio: 'none'
  }
  this.svg = j3pCreeSVG(this.div, svgProps)
  // axe principal qui déborde (pour le conserver toujours visible au scale, même si on perd les graduations au zoomout ou sur une translation)
  const axe = j3pCreeSegment(this.svg, {
    x1: -10 * this.width,
    y1: this.yMid,
    x2: 10 * this.width,
    y2: this.yMid,
    couleur: this.couleuraxe,
    epaisseur: this.epaisseuraxe
  })
  // 1re grad principale à G
  let absFirstGrad = Math.ceil(this.absStart / this.pas) * this.pas // abscisse de la première graduation principale
  let xFirstGrad = (absFirstGrad - this.absStart) * this.pixelsParUnite // position en pixels de la 1re graduation
  if (!this.negatifs) {
    // faut afficher à partir de 0
    if (this.absStart < 0) {
      absFirstGrad = 0
      xFirstGrad = -this.absStart * this.pixelsParUnite // x de l’origine
      if (xFirstGrad > this.width) {
        // on la ramène d’office dans le champ
        this.feedback('La droite graduée a été repositionnée (elle était sortie du champ de vision)', 'info', 10)
        absFirstGrad = 0
        xFirstGrad = this.pixelsParGraduation / 2
        this.absStart = -0.5 * this.pas
      }
      // on positionne le début de l’axe sur le 0
      axe.setAttribute('x1', xFirstGrad)
    } else if (Number(axe.getAttribute('x1')) < this.absStart * this.pixelsParUnite) {
      // pour que l’axe déborde pas à gauche du 0 (en cas de zoom)
      axe.setAttribute('x1', -this.absStart * this.pixelsParUnite)
    }
  }
  let x = xFirstGrad // en pixel
  let abs = absFirstGrad // abscisse
  const hasSub = this.graduationsecondaires && this.pixelsParGraduation >= this.pixelsminpourgraduations
  const hasSubSub = hasSub && (this.pixelsParGraduation / 10 >= this.pixelsminpourgraduations)
  let label = ''
  let maxDecimales = 0
  if (this.etiquettes && this.pas < 1) {
    maxDecimales = Math.round(Math.abs(Math.log(this.pas) / Math.log(10)))
  }
  // graduations principales
  let n = 0
  while (x < this.width) {
    if (this.etiquettes) {
      label = j3pVirgule(abs, maxDecimales)
    } else if (this.listeEtiquettes) {
      if (n < this.listeEtiquettes.length) {
        label = this.listeEtiquettes[n]
      } else {
        label = ''
      }
      n++
    }
    this._addGrad(x, 1, label)
    abs += this.pas
    x += this.pixelsParGraduation
  }

  // graduations secondaires (on peut pas le mettre dans le while précédent
  // car y’a pas forcément de graduation principale sur l’axe)
  if (hasSub) {
    let i, nbGrad, isHalf
    const nbSubStep = hasSubSub ? 100 : 10
    const subStep = this.pas / nbSubStep
    // abscisse de la première graduation secondaire (ou tertiaire)
    absFirstGrad = Math.ceil(this.absStart / subStep) * subStep
    if (!this.negatifs && absFirstGrad < 0) absFirstGrad = subStep
    xFirstGrad = (absFirstGrad - this.absStart) * this.pixelsParUnite
    x = xFirstGrad
    abs = absFirstGrad
    const pixelsBySubStep = this.pixelsParGraduation / nbSubStep
    while (x < this.width) {
      nbGrad = abs / this.pas
      // indice de 0 à nbSubStep-1
      i = Math.round((nbGrad - Math.floor(nbGrad)) * nbSubStep)
      if (Math.abs(nbGrad - Math.round(nbGrad)) > 0.01) {
        // écrire hors de la partie affichée du svg serait pas un réel pb, mais c’est pas très utile
        if (hasSubSub && (i % 10 !== 0)) {
          this._addGrad(x, 3, '', i % 5 === 0)
        } else {
          // on ne met des labels sur les graduations secondaires que si y’a des tertiaires
          label = (this.etiquettes && hasSubSub) ? j3pVirgule(abs, maxDecimales + 1) : ''
          isHalf = i % (hasSubSub ? 50 : 5) === 0
          this._addGrad(x, 2, label, isHalf)
        }
      } // sinon c’est une graduation principale et on se contente d’incrémenter
      abs += subStep
      x += pixelsBySubStep
    }
  }

  for (const nom in this.points) {
    this.drawPoint(nom)
  }

  this.isRebuilding = false
} // construire

/**
   * @typedef Point
   * @property {string} nom
   * @property {boolean} [etiquette=false] passer true pour afficher l’abscisse en étiquette
   * @property {string} police
   * @property {string} couleur
   * @property {boolean} [fixe=true] passer false pour permettre de bouger le point à la souris
   * @property {number|string} abscisse On accepte les fractions en string
   * @property {number} taillepoint
   * @property {number} taillepolice
   * @property {number} epaisseur
   * @property {number} nbdecimales
   */

/**
   * Ajoute un point
   * @param {Point} point
   */
Droitegraduee.prototype.addPoint = function (point) {
  this.points[point.nom] = point
  this.drawPoint(point.nom)
}

/**
   * Marque un point (qui doit exister dans la liste this.points) sur la droite
   * @param {string} nom
   */
Droitegraduee.prototype.drawPoint = function (nom) {
  const point = this.points[nom]
  if (!point) return console.error(Error('point invalide'), nom)
  // on vire d’abord les elts s’il y en a
  if (point.elements) {
    point.elements.forEach(function (elt) {
      if (elt && elt.parentNode) elt.parentNode.removeChild(elt)
    })
  }
  // on cherche d’abord l’abscisse du point, qui peut être une fraction (en string)
  let abs, frac
  try {
    if (typeof point.abscisse === 'string') {
      if (/\//.test(point.abscisse)) {
        frac = j3pParseFrac(point.abscisse)
        abs = frac.value
      } else {
        abs = Number(point.abscisse)
      }
    } else if (typeof point.abscisse === 'number') {
      abs = point.abscisse
    }
    if (isNaN(abs)) throw Error('abscisse invalide')
  } catch (error) {
    console.error(error, 'avec le point', point)
    // du coup on fait rien mais on plante pas toute la suite pour ça
    return
  }
  // abs est bien un number, et frac existe si c'était une fraction

  const x = (abs - this.absStart) * this.pixelsParUnite
  point.elements = []
  point.elements.push(j3pCreeSegment(this.svg, {
    id: j3pGetNewId('pointM'),
    x1: x,
    y1: this.yMid - point.taillepoint / 2,
    x2: x,
    y2: this.yMid + point.taillepoint / 2,
    couleur: point.couleur,
    epaisseur: point.epaisseur
  }))
  let etiquette = ''
  if (point.etiquette) {
    const props = {
      x,
      y: this.yMid + 35 + this.tailleetiquetteprincipale,
      centerX: true,
      centerY: true
    }
    if (point.taillepolice) props.taille = point.taillepolice
    if (point.couleur) props.couleur = point.couleur
    if (point.fonte) props.fonte = point.fonte
    if (frac) {
      // on dessine la fraction en svg, pénible car le y d’un <text> correspond au bas,
      // et le caractère ne prend pas toute la hauteur de son rectangle
      // on centre donc sur la barre de fraction avec l’attribut dominant-baseline
      // qui va centrer le caractère dans son rectangle en hauteur
      etiquette = String(frac.num)
      props.texte = etiquette
      const numElt = j3pCreeTexte(this.svg, props)
      const style = getComputedStyle(numElt)
      numElt.style.lineHeight = style.fontSize // pour que le rectangle du texte soit aussi serré que possible
      point.elements.push(numElt)
      const rectNum = numElt.getBoundingClientRect()
      const decalY = point.epaisseur + rectNum.height / 2 // texte centré en y avec dominantBaseline ci-dessus
      const yBarre = props.y + decalY
      // la barre de fraction devra être ajoutée en dernier (suivant le plus long entre num et den)
      // le dénominateur
      props.y += point.epaisseur * 2 + rectNum.height
      props.texte = String(frac.den)
      const denElt = j3pCreeTexte(this.svg, props)
      denElt.style.lineHeight = style.fontSize
      point.elements.push(denElt)
      const rectDen = denElt.getBoundingClientRect()
      const midLength = Math.max(rectNum.width, rectDen.width) * 0.75
      const barre = j3pCreeSegment(this.svg, {
        x1: x - midLength,
        y1: yBarre,
        x2: x + midLength,
        y2: yBarre,
        couleur: point.couleur,
        epaisseur: point.epaisseur
      })
      point.elements.push(barre)
    } else {
      etiquette = j3pVirgule(abs, point.nbdecimales)
      props.texte = etiquette
      point.elements.push(j3pCreeTexte(this.svg, props))
    }
  } // fin etiquette

  // le nom
  point.elements.push(j3pCreeTexte(this.svg, {
    x,
    y: this.yMid - 14, // au dessus de l’axe
    texte: point.nom,
    taille: point.taillepolice,
    couleur: point.couleur,
    italique: false,
    fonte: point.police,
    centerX: true
  }))

  if (point.fixe === false) {
    // un rectangle pour chopper le point
    const cadre = j3pCreeRectangle(this.svg, {
      x: x - 5,
      y: this.yMid - 10,
      width: 10,
      height: 20,
      couleur: 'black',
      couleurRemplissage: 'yellow',
      opaciteRemplissage: 0.5,
      epaisseur: 0
    })
    point.elements.push(cadre)
    cadre.style.cursor = 'grab'
    // on ajoute des listeners sur le cadre pour bouger ce point
    // bind renvoie une fct, ça génère donc un nouveau listener à chaque fois qu’on repasse ici
    // (avec le nom fixé lors de l’appel du bind)
    const startListener = onMouseDownPoint.bind(this, cadre, point.nom)
    cadre.addEventListener('mousedown', startListener, listenerOptions)
    cadre.addEventListener('touchstart', startListener, listenerOptions)
    // pas de mouseup / mouseout sur le cadre du point, c’est celui du div qui fera le job
  }
} // drawPoint

// méthodes "privées" (on indique par le préfixe _ qu’elles ne devraient pas être appelées
// en dehors de ce fichier, mais de fait elles sont publiques et pourraient l'être)

/**
   * Ajoute une graduation (et l’étiquette éventuelle)
   * @param {number} x Position en pixel sur l’axe
   * @param {number} level (1 principale, 2 secondaire, le reste tertiaire)
   * @param {string} [label]
   * @param {boolean} [isHalf=false] passer true si on est sur une demi-graduation supérieure
   */
Droitegraduee.prototype._addGrad = function (x, level, label, isHalf) {
  let demiLong, couleur, epaisseur, taille
  // pour savoir si on est sur une grad principale, on utilise le modulo, mais ça ne marque que sur des pas > 1
  if (level === 1) {
    // graduation principale
    demiLong = this.taillesegmentprincipal / 2
    couleur = this.couleurgraduationprincipale
    epaisseur = this.epaisseurgraduationprincipale
    taille = this.tailleetiquetteprincipale
  } else if (level === 2) {
    // graduation secondaire
    demiLong = this.taillesegment / 2
    couleur = this.couleurgraduation
    epaisseur = this.epaisseurgraduation
    taille = this.tailleetiquette
  } else {
    demiLong = this.taillesegmentpetit / 2
    couleur = this.couleurgraduationpetite
    epaisseur = 1
    taille = this.tailleetiquette
  }
  // on ajoute 50% si demi graduation
  if (isHalf) {
    epaisseur *= 1.5
    demiLong *= 1.5
  }
  j3pCreeSegment(this.svg, {
    x1: x,
    y1: this.yMid - demiLong,
    x2: x,
    y2: this.yMid + demiLong,
    couleur,
    epaisseur
  })
  if (label) {
    j3pCreeTexte(this.svg, {
      x,
      y: this.yMid + demiLong + 18,
      texte: label,
      taille,
      couleur,
      italique: false,
      fonte: 'times new roman',
      centerX: true
    })
  }
}

/**
   * Affiche un message sous la règle
   * @param {string} [message=''] laisser vide pour effacer le message précédent
   * @param {string} [className=info] passer info|error|warning (sera mis à vide si pas de message)
   * @param {number} [ttl] Délai avant effacement en secondes (passer 0 pour ne pas effacer), mis à 5 par défaut pour info (sinon 0)
   */
Droitegraduee.prototype.feedback = function (message, className, ttl) {
  if (typeof message !== 'string') message = ''
  this.infos.innerText = message
  // on vire la classe si pas de message
  if (!message) className = ''
  // sinon c’est info par défaut
  if (typeof className !== 'string') className = 'info'
  this.infos.className = className
  if (typeof ttl !== 'number') ttl = (className === 'info' ? 5 : 0)
  if (ttl > 0) {
    setTimeout(this.feedback.bind(this), ttl * 1000)
  }
}

Droitegraduee.prototype._getMouseAbscisse = function (event) {
  const mousePos = j3pGetMousePositionInElt(event, this.svg)
  return this.absStart + mousePos.x / this.pixelsParUnite
}

// les listeners (qui seront utilisés avec du bind, leur this est donc bien Droitegraduee

/**
   * Listener des boutons de zoom décimal
   * @private
   * @this Droitegraduee
   */
function onClickBtnZoom (zoomCase) {
  if (this.enCours === zoomCase) {
    // annulation
    this.enCours = ''
    this.feedback()
  } else if (zoomCase === zoomIn) {
    this.enCours = zoomIn
    this.btnWrapper.style.cursor = 'zoom-in'
    this.btnZoomIn.className = 'MepBoutonsDG2plusenfonce'
    this.btnZoomOut.className = 'MepBoutonsDG2moins'
    this.feedback('Clique sur la droite pour zoomer (×10)', 'info', 0)
  } else if (zoomCase === zoomOut) {
    this.enCours = zoomOut
    this.btnWrapper.style.cursor = 'zoom-out'
    this.btnZoomIn.className = 'MepBoutonsDG2plus'
    this.btnZoomOut.className = 'MepBoutonsDG2moinsenfonce'
    this.feedback('Clique sur la droite pour un zoom arrière (×10)', 'info', 0)
  } else {
    console.error(Error('onClickBtnZoom appelé avec un paramètre incorrect (' + zoomCase + ')'))
  }
  refreshBtnZoom.call(this)
}

/**
 * Mets les bonnes icones de boutons (ne touche pas au feedback)
 * @this Droitegraduee
 * @private
 */
function refreshBtnZoom () {
  this.btnWrapper.style.cursor = 'auto'
  this.btnZoomIn.disabled = this.currentZoomLevel >= this.maxZoomDecimal
  if (this.btnZoomIn.disabled) {
    this.btnZoomIn.className = 'MepBoutonsDG2plusdisabled'
  } else {
    this.btnZoomIn.className = this.enCours === zoomIn ? 'MepBoutonsDG2plusenfonce' : 'MepBoutonsDG2plus'
  }
  this.btnZoomOut.disabled = this.currentZoomLevel <= -this.maxZoomDecimal
  if (this.btnZoomOut.disabled) {
    this.btnZoomOut.className = 'MepBoutonsDG2moinsdisabled'
  } else {
    this.btnZoomOut.className = this.enCours === zoomOut ? 'MepBoutonsDG2moinsenfonce' : 'MepBoutonsDG2moins'
  }
}

/**
 * Le listener click sur le svg, pour réagir à placerPoint et zoom décimal
 * @param event
 * @this Droitegraduee
 * @private
 */
function onClick (event) {
  if (this.enCours === placerPoint) {
    onClickPlacerPoint.call(this, event)
  } else if (this.enCours === zoomIn) {
    onClickZoomInDec.call(this, event)
  } else if (this.enCours === zoomOut) {
    onClickZoomOutDec.call(this, event)
  }
}

/**
 * Helper onClick pour le cas placerPoint
 * @param event
 * @this Droitegraduee
 * @private
 */
function onClickPlacerPoint (event) {
  const nbPoints = Object.keys(this.points).length
  if (nbPoints >= this.maxNbPoints) {
    console.error(Error('maxNbPoints déjà atteint !'))
    this.btnPlacer.disabled = true
  }
  // faudra recliquer pour le point suivant
  this.enCours = ''
  this.btnPlacer.className = 'MepBoutonsDG2ajout'
  let nom = this.defaultPoint.nom
  // on a cliqué sur le bouton pour placer un point
  if (this.points[nom]) {
    // pas le premier point faut demander un autre nom
    // eslint-disable-next-line no-alert
    nom = prompt('Nom du point ?')
    if (!nom) {
      // on abandonne
      return
    }
    // faut cloner…
    this.points[nom] = j3pClone(this.defaultPoint)
    // et mettre le bon nom
    this.points[nom].nom = nom
  } else {
    // le premier
    this.points[nom] = this.defaultPoint
  }
  if (nbPoints === this.maxNbPoints - 1) {
    // c’est le dernier qu’on ajoutera
    this.btnPlacer.disabled = true
    this.btnPlacer.className = 'MepBoutonsDG2ajoutdisabled'
    // on vire le bouton
    this.btnPlacer.style.display = 'none'
  }
  this.points[nom].abscisse = this._getMouseAbscisse(event)
  this.construire()
}

/**
   * Helper de onMouseDown pour le cas du zoomIn décimal (pour fixer le centre du zoom)
   * @private
   * @this Droitegraduee
   */
function onClickZoomInDec (event) {
  this.currentZoomLevel++
  this.enCours = ''
  refreshBtnZoom.call(this)
  if (this.currentZoomLevel > this.maxZoomDecimal) {
    // on devrait pas passer là car le btn devrait être disabled
    this.feedback('zoom max atteint (+)', 'error')
    return
  }
  if (this.currentZoomLevel === this.maxZoomDecimal) {
    this.feedback('Zoom max atteint')
    this.enCours = ''
  }

  // zoom decimal sur cette abscisse
  this.pas = this.pas / 10
  const mousePos = j3pGetMousePositionInElt(event, this.svg)
  // absStart1 + x / pixelsParUnite = absStart2 + x / (pixelsParUnite*10)
  // => absStart2 = absStart1 + 9x / (pixelsParUnite*10)
  this.absStart += mousePos.x * 9 / (this.pixelsParUnite * 10)
  this.construire()
}

/**
   * Helper de onMouseDown pour le cas du zoomOut décimal (pour fixer le centre du zoom)
   * @private
   * @this Droitegraduee
   */
function onClickZoomOutDec (event) {
  this.currentZoomLevel--
  refreshBtnZoom.call(this)
  if (this.currentZoomLevel < -this.nbmaxzoomdecimal) {
    console.error(Error('zoom max atteint (-)'))
    this.feedback('zoom arrière max atteint', 'error')
    return
  }
  if (this.currentZoomLevel === -this.maxZoomDecimal) {
    this.feedback('Zoom arrière max atteint')
    this.enCours = ''
  }

  // zoom decimal sur cette abscisse
  this.pas = this.pas * 10
  const mousePos = j3pGetMousePositionInElt(event, this.svg)
  // absStart1 + x / pixelsParUnite = absStart2 + x / (pixelsParUnite/10)
  // => absStart2 = absStart1 - 9x / pixelsParUnite
  this.absStart -= mousePos.x * 9 / this.pixelsParUnite
  this.construire()
} // onClickZoomDec

/**
   * @this Droitegraduee
   * @private
   */
function onMouseDown (event) {
  if (this.hasAxeMobile && !this.enCours) {
    // on active le déplacement
    this.enCours = deplacementAxe
    this.lastMouseX = j3pGetMousePositionInElt(event, this.svg).x
    addListeners.call(this)
  }
}

/**
   * Ajoute les listeners sur le div pour move et end
   * @this Droitegraduee
   * @private
   */
function addListeners () {
  let listener = onMouseMove.bind(this)
  const div = this.div
  div.onmousemove = listener
  div.ontouchmove = listener
  listener = onMouseUpOrOut.bind(this)
  // on utilise les onXxx pour être sûr de jamais avoir de listener en double (au cas où un removeListener passerait à l’as)
  for (const type of ['mouseup', 'mouseout', 'touchcancel', 'touchend', 'touchleave']) {
    div['on' + type] = listener
  }
}
/**
   * Retire les listeners sur le div pour move et end
   * @this Droitegraduee
   * @private
   */
function removeListeners () {
  // on utilise les onXxx pour être sûr de jamais avoir de listener en double (au cas où un removeListener passerait à l’as)
  const div = this.div
    ;['mousemove', 'touchmove', 'mouseup', 'mouseout', 'touchcancel', 'touchend', 'touchleave'].forEach(function (type) {
    div['on' + type] = null
  })
}

/**
   * Listener au move sur le svg, pour les cas deplacementAxe et deplacementPoint
   * @todo Arrêter de détruire / redessiner le svg à chaque fois que la souris bouge d’un pixel ! => Utiliser les positions (le scale pour le zoom continu) et ne redessiner qu’au mouseUp (ou lorsque ça a suffisamment bougé)
   * @this Droitegraduee
   * @private
   */
function onMouseMove (event) {
  // on est appelé à chaque fois que la souris bouge d’un pixel, faut rendre la main le plus vite possible
  if (!this.enCours) return
  if (this.enCours === deplacementAxe) {
    if (this.isRebuilding) return
    const mousePos = j3pGetMousePositionInElt(event, this.svg)
    if (typeof this.lastMouseX !== 'number') this.lastMouseX = mousePos.x
    const xDiff = mousePos.x - this.lastMouseX
    if (xDiff === 0) return
    this.lastMouseX = mousePos.x
    // à priori on déplace le viewBox sans reconstruire
    const vb = this.svg.getAttribute('viewBox').split(' ') // [x, y, w, h]
    vb[0] = Number(vb[0]) - xDiff
    this.svg.setAttribute('viewBox', vb.join(' '))
    if (Math.abs(vb[0]) > this.width / 4) {
      this.construire()
    }
  } else if (this.enCours === deplacementPoint) {
    const point = this.pointCourant && this.points[this.pointCourant]
    if (point) {
      point.abscisse = this._getMouseAbscisse(event)
      if (point.etiquette) {
        this.drawPoint(this.pointCourant)
      } else {
        // pas d’étiquette, suffit de changer le x de chaque élément svg du point
        const x = (point.abscisse - this.absStart) * this.pixelsParUnite
        point.elements.forEach(function (elt) {
          if (elt.hasAttribute('x')) {
            if (elt.tagName === 'rect') elt.setAttribute('x', x - elt.getAttribute('width') / 2)
            else elt.setAttribute('x', x)
          } else if (elt.hasAttribute('x1')) {
            const diff = elt.getAttribute('x2') - elt.getAttribute('x1')
            elt.setAttribute('x1', x - diff / 2)
            elt.setAttribute('x2', x + diff / 2)
          }
        })
      }
    } else {
      console.error('Point à déplacer inconnu ' + this.pointCourant)
      this.enCours = ''
    }
  } // sinon y’a rien à faire au move
}
/**
   * Annule un déplacement (de l’axe ou d’un point) au mouseup ou mouseout sur le div
   * @this Droitegraduee
   * @private
   * @param {Event} event
   */
function onMouseUpOrOut (event) {
  // reset si c’est un truc que le move écoutait, sinon on fait rien
  // pour le mouseout, faut annuler seulement sur le div, pas
  // si c’est du out sur un trait de graduation
  // console.log(event.type, 'avec', this.enCours, 'sur', event.target)
  // pour mouseout et touchleave il faut le prendre en compte que sur le div (pas une graduation)
  if (event.type === 'mouseup' || event.type === 'touchend' || event.type === 'touchcancel' || event.target === this.div) {
    if (this.enCours === deplacementAxe) {
      // à priori faut reconstruire
      this.construire()
    } else if (this.enCours === deplacementPoint) {
      this.pointCourant = ''
      this.div.style.cursor = 'auto'
    }
    this.enCours = ''
    removeListeners.call(this)
  }
}

/**
   * @this Droitegraduee
   * @private
   */
function onClickBtnPlacer () {
  if (this.enCours === placerPoint) {
    this.enCours = ''
    this.btnPlacer.className = 'MepBoutonsDG2ajout'
  } else {
    this.enCours = placerPoint
    this.btnPlacer.className = 'MepBoutonsDG2ajoutenfonce'
  }
}

/**
   * @this Droitegraduee
   * @private
   * @param {HTMLElement} cadre
   * @param {string} nom
   * @param {Event} event
   */
function onMouseDownPoint (cadre, nom, event) {
  this.enCours = deplacementPoint
  this.pointCourant = nom
  cadre.style.cursor = 'grabbing'
  this.div.style.cursor = 'grabbing'
  event.stopPropagation()
  addListeners.call(this)
}

// et on exporte en global notre constructeur