/**
* @module gallery-mathcanvas
*/
/**********************************************************************
* Displays an arithmetical expression the way you would write it on paper.
*
* @main gallery-mathcanvas
* @class MathCanvas
* @extends Widget
* @constructor
* @param config {Object} Widget configuration
*/
function MathCanvas(
/* object */ config)
{
MathCanvas.superclass.constructor.call(this, config);
}
MathCanvas.NAME = "MathCanvas";
MathCanvas.ATTRS =
{
/**
* The function to display.
*
* @attribute func
* @type {Y.MathFunction|String}
*/
func:
{
value: new Y.MathFunction.Input(),
setter: function(value)
{
return Y.Lang.isString(value) ?
Y.MathCanvas.parse(value) : value;
}
},
/**
* The font name to use.
*
* @attribute fontName
* @type {String}
*/
fontName:
{
value: 'sans-serif',
validator: Y.Lang.isString
},
/**
* The font size to use, in em's.
*
* @attribute fontSize
* @type {number}
*/
fontSize:
{
value: 1,
validator: Y.Lang.isNumber
},
/**
* The minimum width of the canvas. If the expression is wider, the
* width will increase to fit.
*
* @attribute minWidth
* @type {Integer}
*/
minWidth:
{
value: 100,
validator: Y.Lang.isNumber
},
/**
* The minimum height of the canvas. If the expression is taller, the
* height will increase to fit.
*
* @attribute minHeight
* @type {Integer}
*/
minHeight:
{
value: 100,
validator: Y.Lang.isNumber
}
};
/**
* <p>Map of localizable strings.</p>
*
* <dl>
* <dt>parse_error</dt>
* <dd>Displayed by `MathCanvas.parse()` when expression parsing fails.</dd>
* <dt>unknown_function</dt>
* <dd>Displayed when the user enters an unknown function name.</dd>
* </dl>
*
* @property Strings
* @type {Object}
* @static
*/
MathCanvas.Strings =
{
parse_error: 'The expression contains an error:\n{line1}\n{line2}',
unknown_function: 'There is no function named "{name}"'
};
/**
* @method parse
* @static
* @param expr {string}
* @return {MathFunction}
*/
MathCanvas.parse = function(
/* string */ expr)
{
try
{
return MathParser.parse(expr);
}
catch (e)
{
if (e.message.startsWith('Parse error'))
{
const s = e.message.split('\n');
alert(Y.Lang.sub(Y.MathCanvas.Strings.parse_error,
{
line1: s[1],
line2: s[2]
}));
}
throw e;
}
};
Y.extend(MathCanvas, Y.Widget,
{
initializer: function(config)
{
this.after('funcChange', function()
{
this.selection = -1;
this._renderExpression();
});
this.after('fontNameChange', this._renderExpression);
this.after('fontSizeChange', this._renderExpression);
this.after('minWidthChange', this._renderExpression);
this.after('minHeightChange', this._renderExpression);
},
destructor: function()
{
this.canvas = null;
this.context = null;
},
renderUI: function()
{
var container = this.get('contentBox');
var w = this.get('minWidth');
this.set('width', w+'px');
var h = this.get('minHeight');
this.set('height', w+'px');
this.canvas = Y.Node.create(
'<svg width="' + w + '" height="' + h + '" tabindex="0" xmlns="http://www.w3.org/2000/svg"></svg>');
if (!this.canvas)
{
throw Error("This browser does not support svg rendering.");
}
container.appendChild(this.canvas);
this.context = math_rendering;
this.context.math_canvas = this;
this._renderExpression();
// input (for mobile)
if (Y.UA.touchEnabled || YUI.config.debug_mathcanvas_keyboard)
{
const fn_names = Object.keys(Y.MathFunction.name_map).sort();
this.keyboard = Y.Node.create(Y.Lang.sub(
'<div class="{clazz}-keyboard">' +
'<div class="operators">' +
'<button value="+">+</button>' +
'<button value="-">–</button>' +
'<button value="*">×</button>' +
'<button value="/">/</button>' +
'<button value="^">^</button>' +
'<button value=",">,</button>' +
'</div>' +
'<div class="digits">' +
'<button class="number" value="1">1</button>' +
'<button class="number" value="2">2</button>' +
'<button class="number" value="3">3</button>' +
'<button class="number" value="4">4</button>' +
'<button class="number" value="5">5</button>' +
'<button class="number" value="6">6</button>' +
'<button class="number" value="7">7</button>' +
'<button class="number" value="8">8</button>' +
'<button class="number" value="9">9</button>' +
'<select class="functions">' +
'<option>\u0192</option>' +
'<optgroup>' +
fn_names.reduce(function(s, n)
{
return s + '<option>' + n + '</option>';
},
'') +
'</optgroup>' +
'</select>' +
'<button class="number" value="0">0</button>' +
'<button class="number" value=".">.</button>' +
'</div>' +
'<div class="misc">' +
'<button value="\u03c0">\u03c0</button>' +
'<button value="e">e</button>' +
'<button value="i">i</button>' +
'<button value="=">=</button>' +
'<button class="delete" value="delete">\u232b</button>' +
'</div>' +
'</div>',
{
clazz: this.getClassName()
}));
container.appendChild(this.keyboard);
this.keyboard.setStyle('bottom', (-this.keyboard.get('offsetHeight'))+'px');
}
},
bindUI: function()
{
this.canvas.on('mousedown', this._handleMouseDown, this);
Y.one(Y.config.doc).on('keydown', this._handleKeyDown, this);
if (this.keyboard)
{
this.keyboard.delegate('click', this._handleKeyboard, 'button', this);
this.keyboard.one('.functions').on('change', function(e)
{
this.applyFunctionToSelection(e.currentTarget.get('value'));
e.currentTarget.set('selectedIndex', 0); // clear menu
this.canvas.focus();
},
this);
}
},
_handleMouseDown: function(e)
{
function getMousePosition(e)
{
const CTM = e.currentTarget.getDOMNode().getScreenCTM();
return [
((e.clientX - CTM.e) / CTM.a) - this.canvas_offset.x,
((e.clientY - CTM.f) / CTM.d) - this.canvas_offset.y
];
};
function select(e)
{
this._deactivateSelection();
this.selection = this.rect_list.getSelection(anchor, getMousePosition.call(this, e));
this._renderExpression();
}
var anchor = getMousePosition.call(this, e);
select.call(this, e);
var handler = this.canvas.on('mousemove', select, this);
Y.one(Y.config.doc).once('mouseup', function(e)
{
handler.detach();
if (this.selection >= 0)
{
this.showKeyboard();
}
else
{
this.hideKeyboard();
}
},
this);
},
_handleKeyDown: function(e)
{
if (!e.altKey && !e.ctrlKey && !e.metaKey && this.selection >= 0)
{
this._handleKeyPress(e.charCode, e._event.key);
e.halt();
}
},
_handleKeyboard: function(e)
{
var op = e.currentTarget.get('value');
if (op == 'hide')
{
this.hideKeyboard();
}
else if (op == 'expand')
{
this.expandSelection();
}
else if (op == 'delete')
{
this.deleteSelection();
}
else
{
this._handleKeyPress(0, op);
}
},
_handleKeyPress: function(
/* int */ code,
/* char */ c)
{
if (code == 9 && this.selection >= 0)
{
this.focusNextInput(this.rect_list.get(this.selection).func);
}
else if (this.selection >= 0 &&
this.rect_list.get(this.selection).func.handleKeyPress(this, code, c))
{
if (!this.rect_list.get(this.selection))
{
this.selection = -1;
}
this._renderExpression();
}
else if (c == ' ')
{
this.expandSelection();
}
else if (code == 8)
{
this.deleteSelection();
}
else if (this.selection >= 0 && c.length == 1 && c != '=')
{
const i = Y.MathFunction.Input.replace(this, this.rect_list.get(this.selection).func);
i.handleKeyPress(this, code, c);
this.selection = -1;
this._renderExpression();
this.selection = this.rect_list.findIndex(i); // selectFunction() deactivates Input
this._renderExpression();
}
if (c == '=')
{
this.fire('evaluate');
this.hideKeyboard();
}
},
/**
* Shows touch keyboard.
*
* @method showKeyboard
*/
showKeyboard: function()
{
if (!this.keyboard)
{
return;
}
if (this.keyboard_anim)
{
this.keyboard_anim.stop();
}
this.keyboard_anim = new Y.Anim(
{
node: this.keyboard,
to:
{
bottom: 0
},
easing: Y.Easing.easeOut,
duration: 0.5
});
this.keyboard_anim.run();
},
/**
* Hides touch keyboard.
*
* @method hideKeyboard
*/
hideKeyboard: function()
{
if (!this.keyboard)
{
return;
}
if (this.keyboard_anim)
{
this.keyboard_anim.stop();
}
this.keyboard_anim = new Y.Anim(
{
node: this.keyboard,
to:
{
bottom: -this.keyboard.get('offsetHeight')
},
easing: Y.Easing.easeOut,
duration: 0.5
});
this.keyboard_anim.run();
},
/**
* Expands the selection up one level of the parse tree.
*
* @method expandSelection
*/
expandSelection: function()
{
if (this.selection == -1)
{
return;
}
this._deactivateSelection();
var p = this.rect_list.get(this.selection).func.getParent();
if (p)
{
do
{
this.selection = this.rect_list.findIndex(p);
p = p.getParent();
}
while (this.selection == -1);
this._renderExpression();
}
},
/**
* Selects the specified function, if it can be found.
*
* @method selectFunction
* @param f {MathFunction}
* @return true if selected
*/
selectFunction: function(
/* MathFunction */ f)
{
this._renderExpression(); // update rect_list
const i = this.rect_list.findIndex(f);
if (i >= 0)
{
this._deactivateSelection();
this.selection = i;
this._renderExpression();
return true;
}
else
{
return false;
}
},
/**
* Switches focus to the next Input, if it can find one.
*
* @method focusNextInput
*/
focusNextInput: function(
/* Input */ f)
{
const i = this.rect_list.findIndex(f);
if (i < 0)
{
return;
}
function checkForInput()
{
const info = this.rect_list.get(j);
if (info.func instanceof Y.MathFunction.Input)
{
this._deactivateSelection();
this.selection = j;
this._renderExpression();
return true;
}
}
const count = this.rect_list.size();
for (var j=i+1; j<count; j++)
{
if (checkForInput.call(this))
{
return;
}
}
for (var j=0; j<i; j++)
{
if (checkForInput.call(this))
{
return;
}
}
},
/**
* Deletes the selected sub-expression.
*
* @method deleteSelection
*/
deleteSelection: function()
{
if (this.selection == -1)
{
return;
}
const f = this.rect_list.get(this.selection).func;
if (f instanceof Y.MathFunction.Input && !f.isEmpty())
{
f.clear();
this._renderExpression();
}
else if (f instanceof Y.MathFunction.Input)
{
this.deleteFunction(f);
}
else
{
const i = Y.MathFunction.Input.replace(this, f);
this.selection = -1;
this._renderExpression();
this.selection = this.rect_list.findIndex(i); // selectFunction() deactivates Input
this._renderExpression();
}
},
/**
* @method applyFunctionToSelection
* @param fn_name {string} name of function to apply
* @return true if function name is valid
*/
applyFunctionToSelection: function(
/* string */ fn_name)
{
if (this.selection == -1 || !Y.MathFunction.name_map[ fn_name ])
{
return false;
}
const f = this.rect_list.get(this.selection).func,
p = f.getParent(),
v = Y.MathFunction.name_map[ fn_name ].applyTo(f);
if (p != null)
{
p.replaceArg(f, v);
}
else
{
this.set('func', v);
}
this.selection = -1;
this._renderExpression(); // update rect_list
var select_f = f.getParent(); // parent changed
select_f.getArgs().some(function(a)
{
if (a instanceof Y.MathFunction.Input)
{
select_f = a;
return true;
}
});
this.selection = this.rect_list.findIndex(select_f);
this._renderExpression();
return true;
},
/**
* @method deleteFunction
* @param f {MathFunction} function to remove from the overall expression
*/
deleteFunction: function(
/* MathFunction */ f)
{
var p = f.getParent();
var s = p;
if (!p)
{
s = new Y.MathFunction.Input();
this.set('func', s);
}
else if (p.getArgCount() == 1)
{
this.deleteFunction(p);
return;
}
else if (p.getArgCount() == 2)
{
var s = (p.getArg(0) == f ? p.getArg(1) : p.getArg(0));
var p1 = p.getParent();
if (p1)
{
p1.replaceArg(p, s);
}
else
{
this.selection = -1;
s.parent = null;
this.set('func', s);
}
}
else
{
p.removeArg(f);
}
this.selection = -1;
this._renderExpression(); // update rect_list
this.selection = this.rect_list.findIndex(s);
this._renderExpression();
},
_deactivateSelection: function()
{
if (this.selection >= 0)
{
const f = this.rect_list.get(this.selection).func;
if (f instanceof Y.MathFunction.Input)
{
f.handleKeyPress(this, 13, '\n');
}
}
},
/**
* Renders the expression.
*
* @method _renderExpression
* @protected
*/
_renderExpression: function()
{
if (this.canvas_root)
{
this.canvas_root.remove(true);
}
this.canvas_root = Y.Node(document.createElementNS('http://www.w3.org/2000/svg', 'g'));
this.canvas.appendChild(this.canvas_root);
var f = this.get('func');
if (!f)
{
return;
}
this.rect_list = new RectList();
var top_left = { x:0, y:0 };
f.layout(this.context, top_left, 100, this.rect_list);
var bounds = this.rect_list.getBounds();
function setSize(
/* width/height */ type)
{
var c = type.charAt(0).toUpperCase() + type.substr(1);
var v = Math.max(this.get('min'+c), this[ 'render_'+type ]+5);
this.set(type, v+'px');
this.canvas.setAttribute(type, v);
}
this.render_width = RectList.width(bounds);
setSize.call(this, 'width');
this.render_height = RectList.height(bounds);
setSize.call(this, 'height');
this.canvas_offset =
{
x: Math.floor((this.canvas.getAttribute('width') - RectList.width(bounds)) / 2),
y: Math.floor((this.canvas.getAttribute('height') - RectList.height(bounds)) / 2)
};
this.canvas_root.setAttribute('transform', Y.Lang.sub('translate({x},{y})', this.canvas_offset));
if (this.selection >= 0)
{
this.context.drawSelection(this.rect_list.get(this.selection).rect);
}
f.render(this.context, this.rect_list);
}
});
var paren_angle = Math.PI/6; // 30 degrees
var math_rendering =
{
_text_node: null,
drawString: function(
/* int */ left,
/* int */ midline,
/* percentage */ font_size,
/* string */ s)
{
var n = this._createNode('text',
{
x: left,
y: midline,
'dominant-baseline': 'middle',
clazz: 'text'
});
this._setFont(n, font_size);
n.innerHTML = s;
return n;
},
getLineHeight: function(
/* percentage */ font_size)
{
this._createTextNode();
this._setFont(this._text_node, font_size);
this._text_node.innerHTML = "Mg";
return this._text_node.getBBox().height;
},
getStringWidth: function(
/* percentage */ font_size,
/* string */ text)
{
this._createTextNode();
this._setFont(this._text_node, font_size);
this._text_node.innerHTML = text;
return this._text_node.getComputedTextLength()*1.1;
},
getSpaceWidth: function(
/* percentage */ font_size)
{
return this.getStringWidth(font_size, ' ');
},
_createTextNode: function()
{
if (!this._text_node)
{
this._text_node = this._createNode('text',
{
x: 0,
y: -1000,
visibility: 'hidden'
});
this.math_canvas.canvas.appendChild(this._text_node);
}
},
_setFont: function(
/* Node */ node,
/* percentage */ font_size)
{
node.setAttribute('font-family', this.math_canvas.get('fontName'));
node.setAttribute('font-size', (this.math_canvas.get('fontSize') * font_size/100.0) + 'em');
},
getSuperSubFontSize: function(
/* percentage */ font_size)
{
var v = font_size * 0.6;
return Math.max(v, 40);
},
getSuperscriptHeight: function(
/* rect */ r)
{
return RectList.height(r)*2/3;
},
getSubscriptDepth: function(
/* rect */ r)
{
return RectList.height(r)/2;
},
drawSquareBrackets: function(
/* rect */ r)
{
var h = r.bottom - r.top,
w = this.getSquareBracketWidth(r)-2;
return [
this.drawLines(
r.left-2, r.top,
'd', -w, null,
null, 'd', h-1,
'd', w, null),
this.drawLines(
r.right+1, r.top,
'd', w, null,
null, 'd', h-1,
'd', -w, null)
];
},
getSquareBracketWidth: function(
/* rect */ r)
{
var h = r.bottom - r.top;
return Math.round(3+((h-1)/10));
},
drawParentheses: function(
/* rect */ r)
{
var h = r.bottom - r.top,
radius = h/(2.0*Math.sin(paren_angle)),
yc = RectList.ycenter(r),
pw = this.getParenthesisWidth(r);
return [
this.drawArc(r.left - pw + radius, yc, radius, Math.PI-paren_angle, Math.PI+paren_angle, false),
this.drawArc(r.right + pw - radius, yc, radius, paren_angle, -paren_angle, true)
];
},
getParenthesisWidth: function(
/* rect */ r)
{
var h = r.bottom - r.top;
return 2+Math.round(0.5 + (h * (1.0 - Math.cos(paren_angle)))/(2.0 * Math.sin(paren_angle)));
},
drawVerticalBar: function(
/* rect */ r)
{
return this.drawLines(
r.left+1, r.top,
null, r.bottom);
},
getVerticalBarWidth: function()
{
return 3;
},
drawHorizontalBar: function(
/* rect */ r)
{
return this.drawLines(
r.left, r.top+1,
r.right-1, null);
},
getHorizontalBarHeight: function()
{
return 3;
},
drawLines: function(/* x,y,...,x,y */)
{
var x = arguments[0],
y = arguments[1];
var d = 'M ' + x + ' ' + y;
var i = 2;
while (i < arguments.length)
{
var z = this._parseLineValue(arguments[i], arguments[i+1], x);
var x1 = z[0];
i += z[1];
z = this._parseLineValue(arguments[i], arguments[i+1], y);
var y1 = z[0];
i += z[1];
d += ' L ' + x1 + ' ' + y1;
x = x1;
y = y1;
}
return this._createNode('path',
{
d: d,
clazz: 'path'
});
},
_parseLineValue: function(a1, a2, v)
{
if (a1 === null)
{
return [v, 1];
}
else if (a1 === 'd')
{
return [v + a2, 2];
}
else
{
return [a1, 1];
}
},
// from https://github.com/gliffy/canvas2svg
drawArc: function(xc, yc, radius, startAngle, endAngle, counterClockwise)
{
var startX = xc+radius*Math.cos(startAngle),
startY = yc+radius*Math.sin(startAngle),
endX = xc+radius*Math.cos(endAngle),
endY = yc+radius*Math.sin(endAngle),
largeArcFlag = 0,
diff = endAngle - startAngle;
if (diff < 0)
{
diff += 2*Math.PI;
}
if (counterClockwise)
{
largeArcFlag = diff > Math.PI ? 0 : 1;
}
else
{
largeArcFlag = diff > Math.PI ? 1 : 0;
}
return this._createNode('path',
{
d: Y.Lang.sub('M {x1} {y1} A {r} {r} 0 {a} {s} {x2} {y2}',
{
x1: startX,
y1: startY,
r: radius,
a: largeArcFlag,
s: counterClockwise ? 0 : 1,
x2: endX,
y2: endY
}),
clazz: 'path'
});
},
drawSelection: function(
/* map */ r)
{
return this._createNode('rect',
{
x: r.left,
y: r.top,
width: RectList.width(r),
height: RectList.height(r),
clazz: 'selection'
});
},
_createNode: function(
/* string */ type,
/* map */ attr)
{
var n = document.createElementNS('http://www.w3.org/2000/svg', type);
var clazz = this.math_canvas.getClassName('node');
if (attr.clazz)
{
clazz += ' ' + attr.clazz;
delete attr.clazz;
}
n.setAttribute('class', clazz);
Y.each(attr, function(value, name)
{
n.setAttribute(name, value);
});
this.math_canvas.canvas_root.appendChild(n);
return n;
}
};
MathParser.yy.MathFunction = Y.MathFunction;
Y.MathCanvas = MathCanvas;
Y.MathCanvas.RectList = RectList;
/**********************************************************************
* Parser used to convert a string expression into Y.MathFunction
*
* @class Parser
* @namespace MathCanvas
*/
/**
* Parses a string into a Y.MathFunction.
*
* @method parse
* @static
* @param expr {String} expression to parse
* @return {MathFunction}
*/