main
František Špaček 2 years ago
parent 23a92f9660
commit a0c52e4cdb

@ -358,7 +358,6 @@ class ChartLoader {
*/
async addInteractivity() {
setTimeout(() => {
console.time("2")
// Set dimensions of effect canvas
this.effectCanvas.width = this.canvas.width
this.effectCanvas.height = this.canvas.height
@ -367,7 +366,6 @@ class ChartLoader {
this.detectionCanvas.height = this.canvas.height
// Draw detection map on the detection canvas
this.chart.drawDetectionMap(this.detectionCanvas.getContext("2d"))
console.timeEnd("2")
}, 0)
}
@ -377,7 +375,6 @@ class ChartLoader {
* @param {Array} data - The data to visualize on the chart.
*/
drawChart(chartSettings, data) {
console.time("1")
let zoomManager = new ZoomManager(chartSettings.horizontalZoom, chartSettings.verticalZoom)
// Choose the correct type of chart
@ -419,8 +416,6 @@ class ChartLoader {
this.chart.draw()
console.timeEnd("1")
this.addInteractivity()
this.addListeners(this.chart)
}

@ -17,47 +17,56 @@ class AreaChart extends PointChart {
/**
* Draws the area chart on the canvas.
* @param {Boolean} async - Says if the chart should be drawn synchronously or asynchronously
*/
draw() {
draw(async = true) {
// Clear the canvas and draw the axis
this.clear()
this.drawAxis()
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
let xmax = 0 // rightmost point (some lines can be shorter)
// Iterate over each value in the category
for (let i = 0; i < this.dataLen; i++) {
// Skip empty points
if (categ.values[i] === null) continue
// Calculate the x and y coordinates for the current value
let pos = this.getPointPos(i, categ.values[i])
xmax = pos.x
this.ctx.lineTo(pos.x, pos.y)
}
this.ctx.stroke()
// Complete the area by drawing lines to the x-axis and back to the starting point
this.ctx.lineTo(xmax, this.zoomBounds.xAxis)
this.ctx.lineTo(this.zoomBounds.left, this.zoomBounds.xAxis)
this.ctx.globalAlpha = 0.5
this.ctx.fillStyle = categ.color
this.ctx.closePath()
this.ctx.fill()
// Reset global alpha for future drawings
this.ctx.globalAlpha = 1
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => {this.drawPoints(categ.values, colId, categ.color)})
this.ctx.lineWidth = this.settings.lineWidth
// Define function for async
let fn = () => {
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
let xmax = 0 // rightmost point (some lines can be shorter)
// Iterate over each value in the category
for (let i = 0; i < this.dataLen; i++) {
// Skip empty points
if (categ.values[i] === null) continue
// Calculate the x and y coordinates for the current value
let pos = this.getPointPos(i, categ.values[i])
xmax = pos.x
this.ctx.lineTo(pos.x, pos.y)
}
this.ctx.stroke()
// Complete the area by drawing lines to the x-axis and back to the starting point
this.ctx.lineTo(xmax, this.zoomBounds.xAxis)
this.ctx.lineTo(this.zoomBounds.left, this.zoomBounds.xAxis)
this.ctx.globalAlpha = 0.5
this.ctx.fillStyle = categ.color
this.ctx.closePath()
this.ctx.fill()
// Reset global alpha for future drawings
this.ctx.globalAlpha = 1
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => { this.drawPoints(categ.values, colId, categ.color) })
}
// Draw the chart
async ? setTimeout(() => fn(), 0) : fn()
}
}

@ -17,34 +17,43 @@ class LineChart extends PointChart {
/**
* Draws the line chart on the canvas.
* @param {Boolean} async - Says if the chart should be drawn synchronously or asynchronously
*/
draw() {
draw(async = true) {
// Clear the canvas and draw the axis
this.clear()
this.drawAxis()
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
// Iterate over each value in the category
for (let i = 0; i < this.dataLen; i++) {
if (categ.values[i] === null) continue
// Calculate the x and y coordinates for the current value
let pos = this.getPointPos(i, categ.values[i])
this.ctx.lineTo(pos.x, pos.y)
}
this.ctx.stroke()
this.ctx.closePath()
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => {this.drawPoints(categ.values, colId, categ.color)})
this.ctx.lineWidth = this.settings.lineWidth
// Define function for async
let fn = () => {
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
// Iterate over each value in the category
for (let i = 0; i < this.dataLen; i++) {
if (categ.values[i] === null) continue
// Calculate the x and y coordinates for the current value
let pos = this.getPointPos(i, categ.values[i])
this.ctx.lineTo(pos.x, pos.y)
}
this.ctx.stroke()
this.ctx.closePath()
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => { this.drawPoints(categ.values, colId, categ.color) })
}
// Draw the chart
async ? setTimeout(() => fn(), 0) : fn()
}
}

@ -17,61 +17,72 @@ class SmoothAreaChart extends PointChart {
/**
* Draws the smooth area chart on the canvas.
* @param {Boolean} async - Says if the chart should be drawn synchronously or asynchronously
*/
draw() {
draw(async = true) {
// Clear the canvas and draw the axis
this.clear()
this.drawAxis()
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
// Calculate the starting point for the line
let pos1 = this.getPointPos(0, categ.values[0])
this.ctx.moveTo(pos1.x, pos1.y)
let xmax = 0 // rightmost point (some lines can be shorter)
// Draw the Bezier curve
for (let i = 0; i < this.dataLen - 1; i++) {
// Calculate left point coordinates
let leftPos = this.getPointPos(i, categ.values[i])
// Calculate right point coordinates
let rightPos = this.getPointPos(i + 1, categ.values[i + 1])
// Find middle point
let xm = (leftPos.x + rightPos.x) / 2
// Find quarter points
let xl = (leftPos.x + xm) / 2
let xr = (rightPos.x + xm) / 2
// Draw a curve that smoothly connects the points
this.ctx.bezierCurveTo(xl, leftPos.y, xr, rightPos.y, rightPos.x, rightPos.y);
xmax = rightPos.x
}
// Complete the area by connecting the last point to the x-axis
this.ctx.lineTo(xmax, this.zoomBounds.xAxis)
this.ctx.lineTo(this.zoomBounds.left, this.zoomBounds.xAxis)
// Set transparency and fill the area
this.ctx.globalAlpha = 0.5
this.ctx.fillStyle = categ.color
this.ctx.closePath()
this.ctx.fill()
// Reset transparency
this.ctx.globalAlpha = 1
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => {this.drawPoints(categ.values, colId, categ.color)})
this.ctx.lineWidth = this.settings.lineWidth
// Define function for async
let fn = () => {
// Iterate over each category in the data
this.data.forEach(categ => {
// Begin a new path for drawing lines
this.ctx.beginPath()
this.ctx.lineJoin = "round"
this.ctx.strokeStyle = categ.color
// Calculate the starting point for the line
let pos1 = this.getPointPos(0, categ.values[0])
this.ctx.moveTo(pos1.x, pos1.y)
let xmax = 0 // rightmost point (some lines can be shorter)
// Draw the Bezier curve
for (let i = 0; i < this.dataLen - 1; i++) {
// Calculate left point coordinates
let leftPos = this.getPointPos(i, categ.values[i])
// Calculate right point coordinates
let rightPos = this.getPointPos(i + 1, categ.values[i + 1])
// Find middle point
let xm = (leftPos.x + rightPos.x) / 2
// Find quarter points
let xl = (leftPos.x + xm) / 2
let xr = (rightPos.x + xm) / 2
// Draw a curve that smoothly connects the points
this.ctx.bezierCurveTo(xl, leftPos.y, xr, rightPos.y, rightPos.x, rightPos.y);
xmax = rightPos.x
}
// Complete the area by connecting the last point to the x-axis
this.ctx.lineTo(xmax, this.zoomBounds.xAxis)
this.ctx.lineTo(this.zoomBounds.left, this.zoomBounds.xAxis)
// Set transparency and fill the area
this.ctx.globalAlpha = 0.5
this.ctx.fillStyle = categ.color
this.ctx.closePath()
this.ctx.fill()
this.ctx.stroke()
// Reset transparency
this.ctx.globalAlpha = 1
})
// Draw points on the chart if required
if (this.settings.displayPoints)
this.data.forEach((categ, colId) => { this.drawPoints(categ.values, colId, categ.color) })
}
// Draw the chart
async ? setTimeout(() => fn(), 0) : fn()
}
}

@ -64,7 +64,6 @@ class SmoothLineChart extends PointChart {
}
// Draw the chart
if (this.settings.displayPoints)
async ? setTimeout(() => fn(), 0) : fn()
async ? setTimeout(() => fn(), 0) : fn()
}
}

@ -13,6 +13,9 @@ class Table {
this.tableHead = tableElement.querySelector("thead")
this.rcMenu = rcMenu
this.importElement = document.getElementById("import")
this.exportElement = document.getElementById("export")
// Initialize right-click menu options
this.rcAddRow = rcMenu.querySelector("#rcAddRow")
this.rcDelRow = rcMenu.querySelector("#rcDelRow")
@ -52,7 +55,89 @@ class Table {
this.rcAddCol.addEventListener("mousedown", (e) => { this.addCol() })
this.rcDelCol.addEventListener("mousedown", (e) => { this.delCol() })
this.get
this.importElement.addEventListener("input", (event) => {
let file = this.importElement.files[0]
let reader = new FileReader()
reader.onload = (e) => {
let result = e.target.result
let rows = result.split("\n")
let header = rows[0].split(",")
rows.shift()
this.selectedCell = this.tableBody.querySelector("#chart_table_0_values_0")
while (this.tableBody.rows.length > 1)
this.delRow()
while (this.tableHead.lastElementChild.childElementCount > header.length)
this.delCol()
while (this.tableHead.lastElementChild.childElementCount < header.length)
this.addCol()
for (let i = 0; i < header.length; i++) {
this.tableHead.querySelector("#chart_table_" + i +"_col_name").value = header[i]
}
for (let i = 1; i < rows.length - 1; i++)
this.addRow()
for (let i = 0; i < rows.length - 1; i++) {
let cells = rows[i].split(",")
for (let j = 0; j < header.length; j++) {
console.log("#chart_table_" + j + "_values_" + i, +cells[j])
this.tableBody.querySelector("#chart_table_" + j + "_values_" + i).value = +cells[j]
}
}
}
reader.readAsText(file)
})
this.exportElement.addEventListener("input", (e) => {
let newName = ""
let fileURL = ""
let choice = this.exportElement.value
if (choice === "png") {
let iframe = document.getElementById("chartDiv")
let canvas = iframe.contentWindow.document.getElementById("chartCanvas")
fileURL = canvas.toDataURL("image/png")
newName = 'chart.png'
}
else if (choice === "csv" || choice === "txt") {
// Variable to store the final csv data
let csv_data = []
let rows = document.getElementsByTagName('tr');
for (let i = 0; i < rows.length; i++) {
// Get each column data
let cols = rows[i].querySelectorAll('td,th');
// Stores each csv row data
let csvrow = [];
for (let j = 0; j < cols.length; j++) {
// Get the text data of each cell of
// a row and push it to csvrow
csvrow.push(cols[j].querySelector("input").value);
}
// Combine each column value with comma
csv_data.push(csvrow.join(","));
}
// Combine each row data with new line character
csv_data = csv_data.join('\n');
let CSVFile = new Blob([csv_data], { type: "text/csv" })
fileURL = window.URL.createObjectURL(CSVFile)
newName = 'data.' + choice
}
let downloadLink = document.createElement("a")
downloadLink.download = newName
downloadLink.href = fileURL
downloadLink.click()
})
}
handleContextMenu(rcMenu, tableElement, pos) {

@ -96,6 +96,10 @@ div[id^="chart_metadata_group"] {
padding: 0.5rem;
}
#fileDiv label {
margin-top: 1rem;
}
/* Styles for secondary div */
#secondaryDiv {
flex-basis: 40%;

@ -167,23 +167,24 @@ h2 {
color: darkred;
}
#myCharts {
#myCharts, #indexDiv {
height: 100%;
width: 100%;
margin: 1rem;
text-align: center;
background-color: var(--main);
display: block;
overflow: auto;
}
#myCharts #createChart {
#createChart {
color: var(--dark);
background-color: green;
padding: 1rem;
transition: 300ms;
}
#myCharts #createChart:hover {
#createChart:hover {
background-color: darkgreen;
}
@ -231,6 +232,25 @@ h2 {
transition: 300ms;
}
#indexDiv {
display: block;
overflow: auto;
}
#indexDiv section{
margin-top: 4rem;
}
#indexDiv iframe {
width: 60%;
height: 22rem;
}
#indexDiv li {
list-style-type: none;
margin: 0.5rem;
}
/* Media query for smaller screens */
@media (max-width: 600px) {
header {

@ -39,13 +39,14 @@ class Chart
"yStep" => 1,
"displaySupportLines" => true,
"displayAxisValues" => true,
"pointSize" => 15,
"pointSize" => 7,
"titleFont" => ["font" => 'Courier New', "size" => 25],
"labelFont" => ["font" => 'Georgia', "size" => 15],
"legendFont" => ["font" => 'Arial', "size" => 10],
"pointBorderSize" => 2,
"pointBorderColor" => "#241f31",
"horizontalZoom" => true
"horizontalZoom" => true,
"lineWidth" => 3
];
#[MongoDB\Field(type: 'hash')]

@ -154,9 +154,21 @@ class MetadataType extends AbstractType
'required' => false,
])
)
// Zoom settings
// Line
->add(
$builder->create('group5', FormType::class, [
'inherit_data' => true,
'label' => 'Nastavení čar',
'label_attr' => ['class' => 'submenuLabel arrow-down']
])
->add('lineWidth', NumberType::class, [
'label' => 'Tloušťka čáry',
'required' => false,
])
)
// Zoom settings
->add(
$builder->create('group6', FormType::class, [
'inherit_data' => true,
'label' => 'Nastavení přiblížení',
'label_attr' => ['class' => 'submenuLabel arrow-down']
@ -172,7 +184,7 @@ class MetadataType extends AbstractType
)
// Background settings
->add(
$builder->create('group6', FormType::class, [
$builder->create('group7', FormType::class, [
'inherit_data' => true,
'label' => 'Nastavení pozadí',
'label_attr' => ['class' => 'submenuLabel arrow-down']

@ -43,14 +43,14 @@ let table = new Table(tableElement, rcMenu)
</div>
<div id="secondaryDiv">
<div id="fileDiv">
<h2>Import - Export</h2>
<label for="import">Nahrát soubor</label>
<input accept=".csv,.txt" id="import" type="file">
<label for="export">Exportovat</label>
<select id="export">
<option value="txt">txt</option>
<option value="csv">csv</option>
<option value="txt">xml</option>
<option value="txt">png</option>
<option value="png">png</option>
</select>
</div>
<div id="tableDiv">

@ -1,16 +1,86 @@
{% extends 'base.html.twig' %}
{% block title %}
Dokumentace
Dokumentace
{% endblock %}
{% block body %}
{{ parent() }}
<h1>Interaktivní grafy</h1>
<section>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Accusamus odio neque est voluptatum
repellat animi dicta aliquam ipsa culpa commodi.
</p>
</section>
<main>
<div id="indexDiv">
<h1>Dokumentace aplikace</h1>
<p>V této dokumentaci se nacházejí ukázky grafů, které je možné pomocí této aplikace vyrobit. Lze je exportovat ve statické formě, nebo je vkládat na webové stránky pomocí:</p>
<p>
<strong>&lt iframe src="example.com/charts/6645cec13703bac5a30e6a41"&gt &lt/iframe &gt
</strong>
</p>
<section>
<iframe src={{ path('charts_display', {'id': "6645cec13703bac5a30e6a41"} ) }}></iframe>
<p>
Bodové grafy jsou základním nástrojem pro vizualizaci datových bodů na rovině. Každý bod v grafu reprezentuje jednu konkrétní hodnotu nebo pozorování. Tyto datové body mohou být v závislosti na datech rovnoměrně nebo nerovnoměrně rozloženy napříč vodorovnou osou. Tento typ grafu je ideální pro zobrazování vztahů mezi dvěma proměnnými a umožňuje identifikovat vzory, korelace a odlehlé hodnoty. Někdy se jim proto říká korelační diagramy. V bodových grafech jsou jednotlivé body umístěny na kartézské soustavě souřadnic. Mohou být v některých případech i trojrozměrné a každý bod tedy popisují tři souřadnice, ale v praxi se tato verze příliš nepoužívá kvůli špatné přehlednosti. Bodové grafy jsou často využívány k analýze dat ve vědeckém výzkumu, ekonomii, sociologii a dalších disciplínách, kde je potřeba vizualizovat vztahy mezi proměnnými.
</p>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "664657fdf6b92077ce0bd5a3"} ) }}></iframe>
<p>
Spojnicové grafy se používají k vizualizaci vztahů mezi body v čase nebo v závislosti na jiné proměnné. Tento typ grafu je vhodný pro zobrazování trendů, vývoje a dynamiky dat. Ve spojnicových grafech jsou body spojeny čarami, které zobrazují vztah mezi nimi. Tyto čáry obvykle představují vývoj dat v čase nebo vztah mezi dvěma proměnnými. Na rozdíl od bodového grafu jsou data téměř vždy rovnoměrně rozprostřena po celé délce vodorovné osy. Spojnicové grafy jsou často využívány k vizualizaci dat jako je vývoj cen, teplot nebo akcií.
</p>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "664659bc3703bac5a30e6a42"} ) }}></iframe>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "66465ab0f6b92077ce0bd5a4"} ) }}></iframe>
<p>
Plošné grafy vizualizují data jako plné oblasti, kde každá oblast reprezentuje jednu datovou kategorii. Tyto grafy jsou ideální pro srovnávání hodnot mezi různými kategoriemi a pro zjištění jejich relativního podílu. Jednou z hlavních výhod plošných grafů jednoduchost interpretace, protože plné plochy poskytují přehledný obraz o datech a umožňují snadno vizuálně porovnávat velikosti jednotlivých kategorií. Plošné grafy mohou nést i některé nevýhody. Mezi ně patří možná ztráta podrobností, pokud je potřeba zobrazit detailnější informace nebo individuální hodnoty. Mohou být často viděny v oblastech jako marketing, ekonomie nebo třeba ve zdravotnictví, pro srovnání prevalence různých onemocnění.
</p>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "66465b9ff6b92077ce0bd5a5"} ) }}></iframe>
<p>
Koláčové grafy jsou kruhové diagramy, které vizualizují data jako procentuální části celku. Každý segment v grafu odpovídá určitému procentu celkového množství. Koláčové grafy jsou často využívány pro prezentaci procentuálního rozložení různých položek, jako jsou například náklady, zisky nebo populace. Jsou také vhodné pro zobrazení struktury portfolia, podílu tržního segmentu a demografických trendů. Mohou však být méně efektivní při zobrazení velkého počtu kategorií a mohou být matoucí, pokud jsou některé segmenty příliš malé na to, aby byly přehledné.
</p>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "66465be2f6b92077ce0bd5a6"} ) }}></iframe>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "66465c073703bac5a30e6a43"} ) }}></iframe>
<p>
Sloupcové grafy jsou vynikajícím nástrojem pro vizualizaci dat, které mají jasně definované kategorie a hodnoty. Data reprezentují pomocí svislých sloupců, kde výška sloupce odpovídá hodnotě datového bodu. Jsou ideální pro srovnávání hodnot mezi různými kategoriemi a rychlé pochopení relativních hodnot. Tento typ grafu je často využíván v různých oblastech, jako jsou ekonomie, marketing, nebo vědecký výzkum, kde je potřeba srovnávat hodnoty mezi různými skupinami nebo kategoriemi dat. Díky své jednoduché interpretaci a přehlednému zobrazení je sloupcový graf oblíbeným nástrojem nejen pro analyzování dat, ale i pro prezentaci výsledků a komunikaci významných trendů nebo rozdílů.
</p>
</section>
<section>
<iframe src={{ path('charts_display', {'id': "66465c4c3703bac5a30e6a44"} ) }}></iframe>
<p>
Skládané grafy představují pokročilejší formu sloupcových grafů, kde každý sloupec zobrazuje kumulativní hodnoty všech kategorií namísto jednotlivých hodnot. Skládané grafy jsou užitečné pro porovnání celkové velikosti a struktury různých skupin dat a používají se proto při vizualizaci trendů, jako jsou například příjmy, náklady nebo produkční výstupy.
</p>
</section>
<h2>API</h2>
<section>
<p>API pro správu grafů poskytuje několik koncových bodů pro získání seznamu grafů, zobrazení detailů konkrétního grafu, vkládání nových grafů, aktualizaci stávajících grafů a mazání grafů.</p>
<ul>
<li><strong>GET /api/charts</strong>: Vrací seznam ID grafů, jež patří přihlášenému uživateli. Odpověď obsahuje seznam ID grafů a HTTP kód 200 OK.</li>
<li><strong>GET /api/charts/{id}</strong>: Endpoint vrací detailní informace o grafu na základě jeho ID. Odpověď obsahuje detailní informace o grafu a kód 200.</li>
<li><strong>POST /api/charts/insert</strong>: Umožňuje uživateli vytvořit nový graf. Data grafu se posílají v těle zprávy ve formátu JSON. Backend následně vytvoří novou entitu a přiřadí jí unikátní ID. Odpověď obsahuje název vytvořeného grafu s HTTP kódem 200. Uživatel musí být přihlášen, jinak se vrátí chyba přístupu s HTTP kódem 403.</li>
<li><strong>POST /api/charts/{id}/update</strong>: Umožňuje uživateli aktualizovat jeho grafy. Data se posílají v těle zprávy jako JSON. Obsahem odpovědi je název aktualizovaného grafu s HTTP kódem 200. Pokud graf patří jinému uživateli, vrátí se chyba přístupu s HTTP kódem 403.</li>
<li><strong>DELETE /api/charts/{id}/remove</strong>: Umožňuje smazat existující graf. Odpověď obsahuje název smazaného grafu s HTTP kódem 200. Uživatel může mazat pouze své vlastní grafy, jinak se vrátí chyba přístupu s kódem 403</li>
</ul>
</section>
</div>
</main>
{% endblock %}

@ -6,13 +6,21 @@
{% block body %}
{{ parent() }}
<div id="indexDiv">
<h1>Interaktivní grafy</h1>
<section>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Accusamus odio neque est voluptatum
repellat animi dicta aliquam ipsa culpa commodi.
</p>
</section>
</div>
<main>
<div id="indexDiv">
<h1>Interaktivní grafy</h1>
<section>
<iframe src={{ path('charts_display', {'id': "66465c073703bac5a30e6a43"} ) }}></iframe>
<p>
Vítejte v aplikaci pro tvorbu interaktivních grafů. Klikněte na tlačítko níže a vytvořte si svůj graf.
</p>
{% if app.user %}
<a href="{{ path('charts_create') }}" id="createChart">Vytvořit nový graf!</a>
{% else %}
<a href="{{ path('users_login') }}" id="createChart">Začít!</a>
{% endif %}
</section>
</div>
</main>
{% endblock %}

Loading…
Cancel
Save

Powered by TurnKey Linux.