Working queries/data ingestion TODO outputting status, admin commands, UI
This commit is contained in:
commit
3d9f9eceed
|
@ -0,0 +1,2 @@
|
||||||
|
shroom_server
|
||||||
|
shrooms.db
|
|
@ -0,0 +1,5 @@
|
||||||
|
module shroom_server
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
|
@ -0,0 +1,67 @@
|
||||||
|
package shroom_internals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO write tests for all of these
|
||||||
|
|
||||||
|
func CreateTable(db *sql.DB) error {
|
||||||
|
// create the table for the shroom data if it doesn't exist
|
||||||
|
create_table := `CREATE TABLE IF NOT EXISTS shrooms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
time INTEGER,
|
||||||
|
humidity REAL,
|
||||||
|
temperature REAL,
|
||||||
|
humidifier_volts REAL
|
||||||
|
);`
|
||||||
|
|
||||||
|
_, err := db.Exec(create_table)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Datapoint struct {
|
||||||
|
Time uint64
|
||||||
|
Temperature float32
|
||||||
|
Humidity float32
|
||||||
|
HumidifierVolts float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryHistory(db *sql.DB, start int64) ([]Datapoint, error) {
|
||||||
|
query := "SELECT time, temperature, humidity, humidifier_volts FROM shrooms WHERE time > ?"
|
||||||
|
rows, err := db.Query(query, start)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sql error: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
results := make([]Datapoint, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
d := Datapoint{}
|
||||||
|
err = rows.Scan(&d.Time, &d.Temperature, &d.Humidity, &d.HumidifierVolts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sql scan error: %w", err)
|
||||||
|
}
|
||||||
|
results = append(results, d)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LatestTime(db *sql.DB) (int64, error) {
|
||||||
|
query := "SELECT MAX(time) FROM shrooms"
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("sql error: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
// i guess the database is empty
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
t := int64(0)
|
||||||
|
err = rows.Scan(&t)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("sql scan error: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package shroom_internals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetRows(db *sql.DB, t int64) ([]byte, error) {
|
||||||
|
results, err := QueryHistory(db, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db read error: %w", err)
|
||||||
|
}
|
||||||
|
msg, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("json marshal error: %w", err)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LastTime(db *sql.DB) ([]byte, error) {
|
||||||
|
t, err := LatestTime(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db read error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := json.Marshal(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("json marshal error: %w", err)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package shroom_internals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newlinePos(s []byte) int {
|
||||||
|
for i, v := range s {
|
||||||
|
if v == '\n' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShroomData struct {
|
||||||
|
Time uint64
|
||||||
|
Temperature float32
|
||||||
|
Humidity float32
|
||||||
|
HumidifierVolts float32
|
||||||
|
Status uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShroomStatusData struct {
|
||||||
|
Status uint32
|
||||||
|
NumConnections int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShroomStatus struct {
|
||||||
|
Status uint32
|
||||||
|
NumConnections int
|
||||||
|
Lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitTcpServer(db *sql.DB, status *ShroomStatus) {
|
||||||
|
// TODO start TCP server for the pipe from the raspberry pi
|
||||||
|
ln, err := net.Listen("tcp", ":9876")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to open tcp server: ", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("tcp accept error: ", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.Lock.Lock()
|
||||||
|
status.NumConnections += 1
|
||||||
|
status.Lock.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
status.Lock.Lock()
|
||||||
|
status.NumConnections -= 1
|
||||||
|
status.Lock.Unlock()
|
||||||
|
log.Println("connection disconnected")
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("connection started")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// TODO deal with the write side of the connection
|
||||||
|
}()
|
||||||
|
|
||||||
|
// deal with the read side of the connection
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
left := buf
|
||||||
|
|
||||||
|
for {
|
||||||
|
num_read, err := conn.Read(left)
|
||||||
|
left = left[num_read:]
|
||||||
|
//log.Println("buf ", buf)
|
||||||
|
//log.Println("left ", left)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("tcp read error: ", err)
|
||||||
|
_ = conn.Close()
|
||||||
|
log.Println("disconnected from client")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// parse the message to see if it's finished
|
||||||
|
unread := buf[:len(buf)-len(left)]
|
||||||
|
|
||||||
|
for newlinePos(unread) != -1 {
|
||||||
|
end := newlinePos(unread)
|
||||||
|
line := unread[:end]
|
||||||
|
unread = unread[end+1:]
|
||||||
|
|
||||||
|
//log.Println("line ", line)
|
||||||
|
//log.Println("unread ", unread)
|
||||||
|
// skip empty lines
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := ShroomData{
|
||||||
|
Time: 0,
|
||||||
|
Temperature: -1,
|
||||||
|
Humidity: -1,
|
||||||
|
HumidifierVolts: -1,
|
||||||
|
Status: 0,
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(line, &data)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("unable to read humdifier data: ", err)
|
||||||
|
log.Println(string(line))
|
||||||
|
log.Println(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data.Time > 0 && data.Temperature > 0 && data.Humidity > 0 && data.HumidifierVolts > 0 {
|
||||||
|
// we got a data packet
|
||||||
|
_, err = db.Exec("INSERT INTO shrooms (time, temperature, humidity, humidifier_volts) VALUES (?, ?, ?, ?)",
|
||||||
|
data.Time, data.Temperature, data.Humidity, data.HumidifierVolts)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("unable to write to database: ", err)
|
||||||
|
}
|
||||||
|
} else if data.Status > 0 {
|
||||||
|
status.Lock.Lock()
|
||||||
|
// TODO change to have more detailed data
|
||||||
|
status.Status = data.Status
|
||||||
|
status.Lock.Unlock()
|
||||||
|
} else {
|
||||||
|
log.Println("unknown packet: ", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// shift the remaining data back to the start of the buffer
|
||||||
|
copy(buf[:len(unread)], unread)
|
||||||
|
left = buf[len(unread):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
import (
|
||||||
|
s "shroom_server/shroom_internals"
|
||||||
|
)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var content embed.FS
|
||||||
|
|
||||||
|
type commandDispatcher struct {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpData(db *sql.DB, offset int64) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, _req *http.Request) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
t := now + offset
|
||||||
|
msg, err := s.GetRows(db, t)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
} else {
|
||||||
|
w.Write(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
db, err := sql.Open("sqlite3", "shrooms.db")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to open db ", err)
|
||||||
|
}
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
log.Fatal("unable to ping db ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.CreateTable(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to create table ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := s.ShroomStatus{}
|
||||||
|
s.InitTcpServer(db, &status)
|
||||||
|
|
||||||
|
contentSub, err := fs.Sub(content, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to use subdirectory of embedded fs: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumpWeek := dumpData(db, -7*24*60*60)
|
||||||
|
dumpDay := dumpData(db, -24*60*60)
|
||||||
|
dumpHour := dumpData(db, -60*60)
|
||||||
|
|
||||||
|
lastPoint := func(w http.ResponseWriter, _req *http.Request) {
|
||||||
|
msg, err := s.LastTime(db)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
} else {
|
||||||
|
w.Write(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus := func(w http.ResponseWriter, _req *http.Request) {
|
||||||
|
status.Lock.RLock()
|
||||||
|
num_connections := status.NumConnections
|
||||||
|
status.Lock.RUnlock()
|
||||||
|
if num_connections > 0 {
|
||||||
|
w.Write([]byte("foo"))
|
||||||
|
}
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
adminHandler := func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Handle("/", http.FileServer(http.FS(contentSub)))
|
||||||
|
http.HandleFunc("/api/data/week", dumpWeek)
|
||||||
|
http.HandleFunc("/api/data/day", dumpDay)
|
||||||
|
http.HandleFunc("/api/data/hour", dumpHour)
|
||||||
|
http.HandleFunc("/api/last_point", lastPoint)
|
||||||
|
http.HandleFunc("/api/status", getStatus)
|
||||||
|
http.HandleFunc("/api/admin", adminHandler)
|
||||||
|
|
||||||
|
// TODO periodically clear old entries from the database
|
||||||
|
|
||||||
|
err = http.ListenAndServe("localhost:8080", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to start server: ", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
}
|
|
@ -0,0 +1,241 @@
|
||||||
|
/*-- Chart --*/
|
||||||
|
.c3 svg {
|
||||||
|
font: 10px sans-serif;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3 path, .c3 line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3 text {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-legend-item-tile,
|
||||||
|
.c3-xgrid-focus,
|
||||||
|
.c3-ygrid,
|
||||||
|
.c3-event-rect,
|
||||||
|
.c3-bars path {
|
||||||
|
shape-rendering: crispEdges;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc path {
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc rect {
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc text {
|
||||||
|
fill: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Axis --*/
|
||||||
|
/*-- Grid --*/
|
||||||
|
.c3-grid line {
|
||||||
|
stroke: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-grid text {
|
||||||
|
fill: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-xgrid, .c3-ygrid {
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Text on Chart --*/
|
||||||
|
.c3-text.c3-empty {
|
||||||
|
fill: #808080;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Line --*/
|
||||||
|
.c3-line {
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Point --*/
|
||||||
|
.c3-circle {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-circle._expanded_ {
|
||||||
|
stroke-width: 1px;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-selected-circle {
|
||||||
|
fill: white;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Bar --*/
|
||||||
|
.c3-bar {
|
||||||
|
stroke-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-bar._expanded_ {
|
||||||
|
fill-opacity: 1;
|
||||||
|
fill-opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Focus --*/
|
||||||
|
.c3-target.c3-focused {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-target.c3-focused path.c3-line, .c3-target.c3-focused path.c3-step {
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-target.c3-defocused {
|
||||||
|
opacity: 0.3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Region --*/
|
||||||
|
.c3-region {
|
||||||
|
fill: steelblue;
|
||||||
|
fill-opacity: 0.1;
|
||||||
|
}
|
||||||
|
.c3-region text {
|
||||||
|
fill-opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Brush --*/
|
||||||
|
.c3-brush .extent {
|
||||||
|
fill-opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Select - Drag --*/
|
||||||
|
/*-- Legend --*/
|
||||||
|
.c3-legend-item {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-legend-item-hidden {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-legend-background {
|
||||||
|
opacity: 0.75;
|
||||||
|
fill: white;
|
||||||
|
stroke: lightgray;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Title --*/
|
||||||
|
.c3-title {
|
||||||
|
font: 14px sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Tooltip --*/
|
||||||
|
.c3-tooltip-container {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
empty-cells: show;
|
||||||
|
-webkit-box-shadow: 7px 7px 12px -9px #777777;
|
||||||
|
-moz-box-shadow: 7px 7px 12px -9px #777777;
|
||||||
|
box-shadow: 7px 7px 12px -9px #777777;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip tr {
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip th {
|
||||||
|
background-color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
text-align: left;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip td {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-left: 1px dotted #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip td > span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip .value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Area --*/
|
||||||
|
.c3-area {
|
||||||
|
stroke-width: 0;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Arc --*/
|
||||||
|
.c3-chart-arcs-title {
|
||||||
|
dominant-baseline: middle;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arcs .c3-chart-arcs-background {
|
||||||
|
fill: #e0e0e0;
|
||||||
|
stroke: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arcs .c3-chart-arcs-gauge-unit {
|
||||||
|
fill: #000;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arcs .c3-chart-arcs-gauge-max {
|
||||||
|
fill: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arcs .c3-chart-arcs-gauge-min {
|
||||||
|
fill: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc .c3-gauge-value {
|
||||||
|
fill: #000;
|
||||||
|
/* font-size: 28px !important;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc.c3-target g path {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-chart-arc.c3-target.c3-focused g path {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-- Zoom --*/
|
||||||
|
.c3-drag-zoom.enabled {
|
||||||
|
pointer-events: all !important;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-drag-zoom.disabled {
|
||||||
|
pointer-events: none !important;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-drag-zoom .extent {
|
||||||
|
fill-opacity: 0.1;
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Load c3.css -->
|
||||||
|
<link href="c3.css" rel="stylesheet" type=text/css>
|
||||||
|
|
||||||
|
<!-- Load d3.js and c3.js -->
|
||||||
|
<script src="d3.v7.min.js" charset="utf-8"></script>
|
||||||
|
<script src="c3.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="chart"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue