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