mirror of
https://github.com/Kioubit/ColorPing
synced 2024-11-23 16:20:40 +08:00
Initial commit
This commit is contained in:
commit
d952c6a454
8 changed files with 559 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.idea/
|
29
README.md
Normal file
29
README.md
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# ColorPing
|
||||||
|
![IPv6 Canvas example](screenshot.png?raw=true)
|
||||||
|
|
||||||
|
## How does it work?
|
||||||
|
Each IPv6 address in a /64 IPv6 subnet is associated to one pixel with color (RGB) information.
|
||||||
|
When an address is pinged, the corresponding pixel is changed on the canvas and displayed
|
||||||
|
to all viewers via a webpage.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
Run and assign a /64 IPv6 subnet to the created interface named `canvas`.
|
||||||
|
The program needs to run as root or with the `CAP_NET_ADMIN` capability
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```
|
||||||
|
./ColorPing
|
||||||
|
ip addr add fdcf:8538:9ad5:3333::1/64 dev canvas
|
||||||
|
ip link set up canvas
|
||||||
|
```
|
||||||
|
### Ping format
|
||||||
|
```
|
||||||
|
????:????:????:????:XXXX:YYYY:11RR:GGBB
|
||||||
|
```
|
||||||
|
Where:
|
||||||
|
- ``????`` can be anything
|
||||||
|
- ``XXXX`` must be the target X coordinate of the canvas, encoded as hexadecimal
|
||||||
|
- ``YYYY`` must be the target Y coordinate of the canvas, encoded as hexadecimal
|
||||||
|
- ``RR`` target "red" value (0-255), encoded as hexadecimal
|
||||||
|
- ``GG`` target "green" value (0-255), encoded as hexadecimal
|
||||||
|
- ``BB`` target "blue" value (0-255), encoded as hexadecimal
|
7
go.mod
Normal file
7
go.mod
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
module ColorPing
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.6.0 // indirect
|
4
go.sum
Normal file
4
go.sum
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||||
|
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
|
||||||
|
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
220
http.go
Normal file
220
http.go
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template.html
|
||||||
|
var embedFS embed.FS
|
||||||
|
var htmlTemplate *template.Template
|
||||||
|
|
||||||
|
type clientState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
INITIAL = 0
|
||||||
|
ACTIVE = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
channel chan string
|
||||||
|
state clientState
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
clientCounter uint32 = 0
|
||||||
|
clientCounterMutex sync.Mutex
|
||||||
|
|
||||||
|
clients = make(map[uint32]*client)
|
||||||
|
clientMutex sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func getClientID() uint32 {
|
||||||
|
clientCounterMutex.Lock()
|
||||||
|
defer clientCounterMutex.Unlock()
|
||||||
|
clientCounter++
|
||||||
|
if clientCounter == math.MaxUint32 {
|
||||||
|
clientCounter = 0
|
||||||
|
clearClients()
|
||||||
|
}
|
||||||
|
return clientCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearClients() {
|
||||||
|
clientMutex.Lock()
|
||||||
|
defer clientMutex.Unlock()
|
||||||
|
clients = make(map[uint32]*client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpServer() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
htmlTemplate = template.Must(template.ParseFS(embedFS, "template.html"))
|
||||||
|
http.HandleFunc("/stream", stream)
|
||||||
|
http.HandleFunc("/", serveRoot)
|
||||||
|
err = http.ListenAndServe("0.0.0.0:9090", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInterfaceBaseIP() string {
|
||||||
|
iFace, err := net.InterfaceByName(interfaceName)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
addresses, err := iFace.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
gua := ""
|
||||||
|
ula := ""
|
||||||
|
for _, v := range addresses {
|
||||||
|
addr := v.String()
|
||||||
|
if !strings.Contains(addr, ":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, anet, err := net.ParseCIDR(addr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if anet.IP.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if anet.IP.IsGlobalUnicast() {
|
||||||
|
gua = strings.Split(anet.String(), "/")[0]
|
||||||
|
}
|
||||||
|
if anet.IP.IsPrivate() {
|
||||||
|
ula = strings.Split(anet.String(), "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gua != "" {
|
||||||
|
return gua
|
||||||
|
} else {
|
||||||
|
return ula
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.RequestURI != "/" {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
_, _ = w.Write([]byte("404 not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type pageData struct {
|
||||||
|
BaseIP string
|
||||||
|
CanvasWidth int
|
||||||
|
CanvasHeight int
|
||||||
|
}
|
||||||
|
baseIP := getInterfaceBaseIP()
|
||||||
|
if len(baseIP) == 21 {
|
||||||
|
baseIP = strings.TrimSuffix(baseIP, ":")
|
||||||
|
}
|
||||||
|
err := htmlTemplate.Execute(w, pageData{
|
||||||
|
BaseIP: baseIP,
|
||||||
|
CanvasHeight: 512,
|
||||||
|
CanvasWidth: 512,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamServer() {
|
||||||
|
for {
|
||||||
|
clientMutex.RLock()
|
||||||
|
if len(clients) == 0 {
|
||||||
|
for {
|
||||||
|
if len(clients) == 0 {
|
||||||
|
clientMutex.RUnlock()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
clientMutex.RLock()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requiresInitial := false
|
||||||
|
requiresUpdate := false
|
||||||
|
for _, v := range clients {
|
||||||
|
if v.state == INITIAL {
|
||||||
|
requiresInitial = true
|
||||||
|
} else {
|
||||||
|
requiresUpdate = true
|
||||||
|
}
|
||||||
|
if requiresInitial && requiresUpdate {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataInitial, dataUpdate := getPicture(requiresInitial, requiresUpdate)
|
||||||
|
|
||||||
|
for _, v := range clients {
|
||||||
|
if v.state == INITIAL {
|
||||||
|
v.state = ACTIVE
|
||||||
|
select {
|
||||||
|
case v.channel <- dataInitial:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if dataUpdate != "0" {
|
||||||
|
select {
|
||||||
|
case v.channel <- dataUpdate:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
clientMutex.RUnlock()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
messageChan := make(chan string, 40)
|
||||||
|
id := getClientID()
|
||||||
|
newClient := client{
|
||||||
|
channel: messageChan,
|
||||||
|
state: INITIAL,
|
||||||
|
}
|
||||||
|
clientMutex.Lock()
|
||||||
|
clients[id] = &newClient
|
||||||
|
clientMutex.Unlock()
|
||||||
|
go func() {
|
||||||
|
// Listen for connection close
|
||||||
|
<-r.Context().Done()
|
||||||
|
clientMutex.Lock()
|
||||||
|
close(messageChan)
|
||||||
|
delete(clients, id)
|
||||||
|
clientMutex.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
data := <-messageChan
|
||||||
|
if data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(data))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
182
main.go
Normal file
182
main.go
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"github.com/songgao/water"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const interfaceName = "canvas"
|
||||||
|
const handlerCount = 4
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
prePopulatePixelArray()
|
||||||
|
packetChan := make(chan *[]byte, 1000)
|
||||||
|
for i := 0; i < handlerCount; i++ {
|
||||||
|
go packetHandler(packetChan)
|
||||||
|
}
|
||||||
|
go startInterface(packetChan)
|
||||||
|
go streamServer()
|
||||||
|
fmt.Println("Kioubit ColorPing started")
|
||||||
|
fmt.Println("Interface name:", interfaceName)
|
||||||
|
httpServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func prePopulatePixelArray() {
|
||||||
|
for x := 0; x < len(pixelArray); x++ {
|
||||||
|
for y := 0; y < len(pixelArray[x]); y++ {
|
||||||
|
pixelArray[x][y] = &pixel{
|
||||||
|
r: uint8(0),
|
||||||
|
g: uint8(0),
|
||||||
|
b: uint8(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startInterface(packetChan chan *[]byte) {
|
||||||
|
config := water.Config{
|
||||||
|
DeviceType: water.TUN,
|
||||||
|
}
|
||||||
|
config.Name = interfaceName
|
||||||
|
iFace, err := water.New(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
packet := make([]byte, 2000)
|
||||||
|
n, err := iFace.Read(packet)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
packet = packet[:n]
|
||||||
|
packetChan <- &packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packetHandler(packetChan chan *[]byte) {
|
||||||
|
for {
|
||||||
|
packet := <-packetChan
|
||||||
|
if len(*packet) < 40 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (*packet)[0] != 0x60 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destinationAddress := (*packet)[24:40]
|
||||||
|
relevant := destinationAddress[8:]
|
||||||
|
// FORMAT: XXXX:YYYY:11RR:GGBB
|
||||||
|
x := binary.BigEndian.Uint16(relevant[0:2])
|
||||||
|
y := binary.BigEndian.Uint16(relevant[2:4])
|
||||||
|
|
||||||
|
if relevant[4] != 0x11 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
r := relevant[5]
|
||||||
|
g := relevant[6]
|
||||||
|
b := relevant[7]
|
||||||
|
|
||||||
|
if x > 512 || y > 512 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := pixelArray[x][y]
|
||||||
|
|
||||||
|
obj.Lock()
|
||||||
|
if obj.r != r || obj.g != g || obj.b != b {
|
||||||
|
obj.r = r
|
||||||
|
obj.g = g
|
||||||
|
obj.b = b
|
||||||
|
obj.changed = true
|
||||||
|
}
|
||||||
|
obj.Unlock()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type pixel struct {
|
||||||
|
sync.Mutex
|
||||||
|
r uint8
|
||||||
|
g uint8
|
||||||
|
b uint8
|
||||||
|
changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 - 513
|
||||||
|
var pixelArray [513][513]*pixel
|
||||||
|
|
||||||
|
func getPicture(fullUpdate bool, incrementalUpdate bool) (string, string) {
|
||||||
|
anyChange := false
|
||||||
|
canvasFullUpdate := image.NewRGBA(image.Rect(0, 0, 512, 512))
|
||||||
|
canvasIncrementalUpdate := image.NewRGBA(image.Rect(0, 0, 512, 512))
|
||||||
|
|
||||||
|
for x := 0; x < len(pixelArray); x++ {
|
||||||
|
for y := 0; y < len(pixelArray[x]); y++ {
|
||||||
|
obj := pixelArray[x][y]
|
||||||
|
obj.Lock()
|
||||||
|
var newColor *color.RGBA
|
||||||
|
if incrementalUpdate {
|
||||||
|
if obj.changed {
|
||||||
|
newColor = &color.RGBA{
|
||||||
|
R: obj.r,
|
||||||
|
G: obj.g,
|
||||||
|
B: obj.b,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
obj.changed = false
|
||||||
|
anyChange = true
|
||||||
|
canvasIncrementalUpdate.SetRGBA(x, y, *newColor)
|
||||||
|
} else if !fullUpdate {
|
||||||
|
obj.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newColor == nil {
|
||||||
|
newColor = &color.RGBA{
|
||||||
|
R: obj.r,
|
||||||
|
G: obj.g,
|
||||||
|
B: obj.b,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obj.Unlock()
|
||||||
|
canvasFullUpdate.SetRGBA(x, y, *newColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := png.Encoder{
|
||||||
|
CompressionLevel: png.BestSpeed,
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementalUpdateResult := "0"
|
||||||
|
if anyChange {
|
||||||
|
buff := new(bytes.Buffer)
|
||||||
|
err := encoder.Encode(buff, canvasIncrementalUpdate)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err.Error())
|
||||||
|
}
|
||||||
|
incrementalUpdateResult = "event: u\ndata:" + base64.StdEncoding.EncodeToString(buff.Bytes()) + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
fullUpdateResult := "0"
|
||||||
|
if fullUpdate {
|
||||||
|
buff := new(bytes.Buffer)
|
||||||
|
err := encoder.Encode(buff, canvasFullUpdate)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err.Error())
|
||||||
|
}
|
||||||
|
fullUpdateResult = "event: u\ndata:" + base64.StdEncoding.EncodeToString(buff.Bytes()) + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullUpdateResult, incrementalUpdateResult
|
||||||
|
}
|
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 MiB |
116
template.html
Normal file
116
template.html
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>IPV6Canvas</title>
|
||||||
|
<style>
|
||||||
|
body{
|
||||||
|
background-color: lightslategrey;
|
||||||
|
}
|
||||||
|
#display{
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: 1em;
|
||||||
|
display: block;
|
||||||
|
border: black 2px;
|
||||||
|
}
|
||||||
|
#collapsed-information{
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: 10em;
|
||||||
|
top: 0;
|
||||||
|
opacity: 84%;
|
||||||
|
color: white;
|
||||||
|
background-color: rgb(100, 100, 200);
|
||||||
|
}
|
||||||
|
#information{
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: 30em;
|
||||||
|
top: 1em;
|
||||||
|
opacity: 80%;
|
||||||
|
padding-left: 0.6em;
|
||||||
|
padding-right: 0.6em;
|
||||||
|
padding-bottom: 0.6em;
|
||||||
|
color: white;
|
||||||
|
background-color: rgb(100, 100, 200);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
height: 25px;
|
||||||
|
width: 25px;
|
||||||
|
background-color: #ff8200;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<div id="collapsed-information">
|
||||||
|
<div class="center"><a style="text-decoration: underline" onclick="infoHandler(true)">Show information</a></div>
|
||||||
|
</div>
|
||||||
|
<div id="information">
|
||||||
|
<h2>IPv6 Canvas</h2>
|
||||||
|
<b>ping {{.BaseIP}}XXXX:YYYY:11RR:GGBB</b>
|
||||||
|
<br>Substitute coordinates and color, then ping. Values are hexadecimal.<br>
|
||||||
|
<br>Canvas size: {{.CanvasWidth}}x{{.CanvasHeight}}<br>
|
||||||
|
Connection status: <span id="connectionStatus" class="dot"></span>
|
||||||
|
<span style="float: right"><a style="text-decoration: underline" onclick="infoHandler(false)">Collapse</a></span>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
<canvas id="display" width="1024" height="1024"></canvas>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById("display");
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
const evtSource = new EventSource("/stream");
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
ctx.mozImageSmoothingEnabled = false;
|
||||||
|
ctx.webkitImageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
//ctx.scale(1.9, 1.9)
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.font = "30px Arial";
|
||||||
|
ctx.fillText("Please wait...", 0, 200,170);
|
||||||
|
evtSource.addEventListener("u", (event) => {
|
||||||
|
let img = new Image();
|
||||||
|
img.src = "data:image/png;base64," + event.data;
|
||||||
|
img.onload = function () {
|
||||||
|
ctx.drawImage(img, 0, 0, 1024, 1024);
|
||||||
|
};
|
||||||
|
img.onerror = function (error) {
|
||||||
|
console.log("Img Onerror:", error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
evtSource.onerror = (err) => {
|
||||||
|
console.log(err)
|
||||||
|
document.getElementById("connectionStatus").style.setProperty("background-color","#d20000")
|
||||||
|
};
|
||||||
|
evtSource.onopen = () => {
|
||||||
|
document.getElementById("connectionStatus").style.setProperty("background-color","#00a30e")
|
||||||
|
};
|
||||||
|
function infoHandler(expand) {
|
||||||
|
if (expand) {
|
||||||
|
document.getElementById("collapsed-information").style.display = "none";
|
||||||
|
document.getElementById("information").style.display = "block";
|
||||||
|
} else {
|
||||||
|
document.getElementById("collapsed-information").style.display = "block";
|
||||||
|
document.getElementById("information").style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</html>
|
Loading…
Reference in a new issue