├── .gitignore ├── config.js.sample ├── README.md ├── views ├── error.jsx ├── controller │ ├── index.jsx │ ├── upload.jsx │ ├── radar.jsx │ ├── user │ │ ├── login.jsx │ │ └── signup.jsx │ ├── table.jsx │ ├── bubble.jsx │ ├── bubble-gov.jsx │ └── drilldown.jsx ├── components │ ├── PageView.js │ ├── BaseComponent.jsx │ ├── Loading.jsx │ ├── fb │ │ └── FBComment.jsx │ ├── bootStrapMenu.jsx │ ├── d3Radar.jsx │ ├── PaginationListView.js │ ├── PaginationBoxView.js │ ├── BudgetGroupTable.jsx │ ├── BudgetTable.jsx │ ├── d3BudgetTreemap.jsx │ └── d3BudgetBubble.jsx ├── helpers │ ├── fb.jsx │ ├── comment.jsx │ ├── d3.jsx │ ├── unitconverter.jsx │ └── util.js ├── dispatch.jsx └── layouts │ ├── default.jsx │ └── front.jsx ├── model ├── budgetfilemodel.jsx ├── budgetmodel.jsx ├── usermodel.jsx ├── s3.jsx └── pg.jsx ├── public ├── css │ ├── radar-chart.min.css │ ├── radar-chart.css │ └── main.css └── js │ ├── radar-chart.min.js │ └── radar-chart.js ├── init.sql ├── routes ├── user.js └── index.js ├── package.json ├── bin └── www ├── app.js ├── webpack.config.js └── budget_list.js.sample /.gitignore: -------------------------------------------------------------------------------- 1 | config.js 2 | budget_list.js 3 | .DS_Store 4 | public/resource 5 | node_modules 6 | sessions 7 | app/ 8 | -------------------------------------------------------------------------------- /config.js.sample: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | db:{ 4 | host: 'localhost', 5 | port: 5432, 6 | database: 'budget', 7 | user: 'root', 8 | password: '' 9 | }, 10 | salt:"xxx3rxdsrfs", 11 | session_secret:'kkkkkkDdasd3', 12 | file_model:false 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # install steps 3 | 4 | * install nodejs (v6) 5 | 6 | * install webpack 7 | 8 | npm install -g webpack 9 | 10 | * install packages 11 | 12 | npm install 13 | 14 | * pack and compile js files 15 | 16 | webpack 17 | 18 | * run server 19 | 20 | node bin/www 21 | -------------------------------------------------------------------------------- /views/error.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | export default class Error extends React.Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.state = {}; 9 | } 10 | 11 | componentWillMount() { 12 | 13 | } 14 | 15 | render() { 16 | return
17 |

Sorry:{this.props.message}

18 |

{this.props.error.status}

19 | /*
{this.props.error.stack}
*/ 20 |
; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /model/budgetfilemodel.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import budgets from "../budget_list.js"; 4 | import Proimse from "bluebird"; 5 | 6 | // 7 | 8 | class BudgetModel{ 9 | 10 | getAll(page,pageSize){ 11 | return Promise.resolve(budgets); 12 | } 13 | 14 | get(ind){ 15 | var filted = budgets.filter((b)=> b.id == ind); 16 | if(filted.length == 0){ 17 | return Promise.resolve(null); 18 | } 19 | return Promise.resolve(filted[0]); 20 | } 21 | } 22 | 23 | export default new BudgetModel(); -------------------------------------------------------------------------------- /views/controller/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseComponent from './../components/BaseComponent.jsx'; 3 | 4 | 5 | export default class Index extends BaseComponent { 6 | render(){ 7 | return ( 8 |
9 |

已完成預算視覺化清單

10 | 11 | 16 |
17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /model/budgetmodel.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import pg from './pg.jsx'; 4 | 5 | // 6 | 7 | class BudgetModel{ 8 | 9 | getAll(page,pageSize){ 10 | return pg.then(function([db,queryResult]){ 11 | return db.query("select * from budget order by ts asc limit ${pageSize} offset ${offset}", 12 | {pageSize:pageSize,offset:(page-1) * pageSize}); 13 | // db.queryResult 14 | }); 15 | } 16 | 17 | get(ind){ 18 | return pg.then(function([db,queryResult]){ 19 | return db.query("select * from budget where id = ${id} ",{id:ind},queryResult.ONE); 20 | // db.queryResult 21 | }); 22 | } 23 | } 24 | 25 | export default new BudgetModel(); -------------------------------------------------------------------------------- /views/controller/upload.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseComponent from './../components/BaseComponent.jsx'; 3 | 4 | 5 | export default class Index extends BaseComponent { 6 | render(){ 7 | return ( 8 |
9 |

上傳新的預算視覺化

10 | 11 |

請上傳對應的 csv 檔(UTF-8 編碼),格式欄位請參考 12 | 範例檔案。 13 |

14 |
15 |
16 | 17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /public/css/radar-chart.min.css: -------------------------------------------------------------------------------- 1 | .radar-chart .level{stroke:grey;stroke-width:.5}.radar-chart .axis line{stroke:grey;stroke-width:1}.radar-chart .axis .legend{font-family:sans-serif;font-size:10px}.radar-chart .axis .legend.top{dy:1em}.radar-chart .axis .legend.left{text-anchor:start}.radar-chart .axis .legend.middle{text-anchor:middle}.radar-chart .axis .legend.right{text-anchor:end}.radar-chart .tooltip{font-family:sans-serif;font-size:13px;transition:opacity 200ms;opacity:0}.radar-chart .tooltip.visible{opacity:1}.radar-chart .area{stroke-width:2;fill-opacity:.5}.radar-chart.focus .area{fill-opacity:.1}.radar-chart.focus .area.focused{fill-opacity:.7}.radar-chart .circle{fill-opacity:.9}.radar-chart .area,.radar-chart .circle{transition:opacity 300ms,fill-opacity 200ms;opacity:1}.radar-chart .d3-enter,.radar-chart .d3-exit{opacity:0} -------------------------------------------------------------------------------- /views/components/PageView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //https://github.com/AdeleD/react-paginate 3 | 4 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 5 | 6 | var React = require('react'); 7 | 8 | var PageView = React.createClass({ 9 | displayName: 'PageView', 10 | 11 | render: function render() { 12 | if (this.props.selected) { 13 | var cssClass = this.props.activeClass || 'selected'; 14 | } 15 | return React.createElement( 16 | 'li', 17 | { className: cssClass }, 18 | React.createElement( 19 | 'a', 20 | _extends({href:''}, this.props), 21 | this.props.page 22 | ) 23 | ); 24 | } 25 | }); 26 | 27 | module.exports = PageView; -------------------------------------------------------------------------------- /views/components/BaseComponent.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | 4 | 5 | export default class BaseComponent extends React.Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = this.state || {}; 10 | } 11 | 12 | isClient(){ 13 | return global.window != null; 14 | } 15 | 16 | setStateWithLoading(state,delay){ 17 | if(delay == null){ 18 | delay = 0; 19 | } 20 | this.setState({_waiting:true},()=>{ 21 | setTimeout(()=>{ 22 | state._waiting = false; 23 | this.setState(state); 24 | },delay); 25 | }); 26 | } 27 | 28 | setUrl(url,title){ 29 | history.pushState({}, title , url); 30 | } 31 | 32 | bindKeyEnter(handler,args){ 33 | var that = this; 34 | var args = []; 35 | for(var i = 1 ; i < arguments.length;++i){ 36 | args.push(arguments[i]); 37 | } 38 | return (function(e){ 39 | if(e.keyCode == 13){ 40 | handler.apply(that,args); 41 | return false; 42 | } 43 | }); 44 | } 45 | 46 | } 47 | 48 | -------------------------------------------------------------------------------- /views/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import cx from 'classnames'; 4 | 5 | 6 | //License:MIT 7 | //reference : http://tobiasahlin.com/spinkit/ 8 | 9 | export default class Loading extends React.Component { 10 | 11 | constructor(props) { 12 | super(props); 13 | } 14 | 15 | render(){ 16 | //TODO: fail back browsers 17 | //only hide when set it to false 18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
); 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /views/helpers/fb.jsx: -------------------------------------------------------------------------------- 1 | 2 | import Promise from "bluebird"; 3 | 4 | 5 | export default { 6 | _load:false, 7 | _promise:null, 8 | init(){ 9 | if(this._load == false){ 10 | this._promise = new Promise((ok,fail)=>{ 11 | window.fbAsyncInit = function() { 12 | FB.init({ 13 | appId : '1627190850901644', 14 | xfbml : true, 15 | version : 'v2.4' 16 | }); 17 | ok(FB); 18 | }; 19 | (function(d, s, id) { 20 | var js, fjs = d.getElementsByTagName(s)[0]; 21 | if (d.getElementById(id)) return; 22 | js = d.createElement(s); js.id = id; 23 | js.src = "//connect.facebook.net/zh_TW/sdk.js#xfbml=1&version=v2.4&appId=1627190850901644"; 24 | fjs.parentNode.insertBefore(js, fjs); 25 | }(document, 'script', 'facebook-jssdk')); 26 | }); 27 | this._load = true; 28 | } 29 | return this._promise; 30 | }, 31 | parse(dom){ 32 | return this.init().then(FB=>{ 33 | var p = new Promise((ok,fail) => { 34 | FB.XFBML.parse(dom,()=> ok(FB)); 35 | }); 36 | return p; 37 | 38 | }); 39 | } 40 | }; -------------------------------------------------------------------------------- /model/usermodel.jsx: -------------------------------------------------------------------------------- 1 | 2 | import sha1 from 'sha1'; 3 | import pg from './pg.jsx'; 4 | import config from '../config'; 5 | 6 | var salt = config.salt; 7 | 8 | class UserModel{ 9 | 10 | insert(account,pwd){ 11 | return pg.then(function([db,queryResult]){ 12 | return db.query("insert into \"user\"(account,pwd) values(${account},${pwd}) returning account,id ", 13 | { 14 | account:account, 15 | pwd:sha1(pwd + salt) 16 | }, 17 | queryResult.ONE); 18 | // db.queryResult 19 | }); 20 | } 21 | checkAccount(account){ 22 | return pg.then(function([db,queryResult]){ 23 | return db.query("select * from \"user\" where account = ${account} ", 24 | { 25 | account:account, 26 | },queryResult.ONE); 27 | // db.queryResult 28 | }); 29 | } 30 | get(account,pwd){ 31 | return pg.then(function([db,queryResult]){ 32 | return db.query("select * from \"user\" where account = ${account} and pwd = ${pwd} ", 33 | { 34 | account:account, 35 | pwd:sha1(pwd + salt) 36 | },queryResult.ONE); 37 | // db.queryResult 38 | }); 39 | } 40 | } 41 | 42 | export default new UserModel(); -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CREATE SEQUENCE untitled_table_id_seq 5 | INCREMENT 1 6 | MINVALUE 1 7 | MAXVALUE 9223372036854775807 8 | START 2 9 | CACHE 1; 10 | 11 | CREATE TABLE budget 12 | ( 13 | id integer NOT NULL DEFAULT nextval('untitled_table_id_seq'::regclass), 14 | name text, 15 | title text, 16 | ogimage text, 17 | description text, 18 | budgets text[], 19 | ts timestamp without time zone DEFAULT now(), 20 | ts_update timestamp without time zone DEFAULT now(), 21 | user_id bigint, 22 | CONSTRAINT untitled_table_pkey PRIMARY KEY (id) 23 | ); 24 | 25 | 26 | INSERT INTO "budget"("id","name","title","ogimage","description","budgets","ts","ts_update","user_id") 27 | VALUES (1,E'台北市',E'台北市 2016 總預算',E'http://tpebudget.tonyq.org/img/ogimage.png',E'快來瞭解台北市 2016 年預算類型、內容!',E'{https://cdn.rawgit.com/tony1223/6a3bee53b175b2d4429f/raw/5e6cffa9d2d6bed87401156c66d3424952a7bf9e/gistfile1.txt,https://api.myjson.com/bins/1vyte}',E'2015-10-04 14:21:50.164221',E'2015-10-04 14:21:50.164221',1), 28 | (2,E'高雄市',E'高雄市 2016 總預算',E'http://ksbudget.tonyq.org/img/ogimage.png',E'快來瞭解高雄市 2016 年預算類型、內容!',E'{http://tony1223.github.io/ks-budget-convert/output/%E6%AD%B2%E5%87%BA%E6%A9%9F%E9%97%9C%E5%88%A5%E9%A0%90%E7%AE%97%E8%A1%A8_g0v.json}',E'2015-10-04 14:51:29.977364',E'2015-10-04 14:51:29.977364',1); -------------------------------------------------------------------------------- /public/css/radar-chart.css: -------------------------------------------------------------------------------- 1 | .radar-chart .level { 2 | stroke: grey; 3 | stroke-width: 0.5; 4 | } 5 | 6 | .radar-chart .axis line { 7 | stroke: grey; 8 | stroke-width: 1; 9 | } 10 | .radar-chart .axis .legend { 11 | font-family: sans-serif; 12 | font-size: 10px; 13 | } 14 | .radar-chart .axis .legend.top { 15 | dy:1em; 16 | } 17 | .radar-chart .axis .legend.left { 18 | text-anchor: start; 19 | } 20 | .radar-chart .axis .legend.middle { 21 | text-anchor: middle; 22 | } 23 | .radar-chart .axis .legend.right { 24 | text-anchor: end; 25 | } 26 | 27 | .radar-chart .tooltip { 28 | font-family: sans-serif; 29 | font-size: 13px; 30 | transition: opacity 200ms; 31 | opacity: 0; 32 | } 33 | .radar-chart .tooltip.visible { 34 | opacity: 1; 35 | } 36 | 37 | /* area transition when hovering */ 38 | .radar-chart .area { 39 | stroke-width: 2; 40 | fill-opacity: 0.5; 41 | } 42 | .radar-chart.focus .area { 43 | fill-opacity: 0.1; 44 | } 45 | .radar-chart.focus .area.focused { 46 | fill-opacity: 0.7; 47 | } 48 | 49 | .radar-chart .circle { 50 | fill-opacity: 0.9; 51 | } 52 | 53 | /* transitions */ 54 | .radar-chart .area, .radar-chart .circle { 55 | transition: opacity 300ms, fill-opacity 200ms; 56 | opacity: 1; 57 | } 58 | .radar-chart .d3-enter, .radar-chart .d3-exit { 59 | opacity: 0; 60 | } -------------------------------------------------------------------------------- /model/s3.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | var AWS = require('aws-sdk'); 5 | 6 | import config from "../config.js"; 7 | 8 | AWS.config.update({accessKeyId: 9 | config.aws.accesskey, 10 | secretAccessKey: config.aws.token 11 | }); 12 | 13 | var s3 = new AWS.S3({region: "ap-northeast-1"}); 14 | var zlib = require("zlib"); 15 | 16 | export default { 17 | putS3:function(){ 18 | 19 | }, 20 | readS3:function(bucket,key){ 21 | if(key == null || bucket == null){ 22 | return Promise.reject(null); 23 | } 24 | console.log("calls3",bucket,key); 25 | 26 | try{ 27 | var params = {Bucket: bucket, Key: key}; 28 | console.log(s3.getSignedUrl('getObject',params)); 29 | 30 | var p = new Promise((ok,fail)=>{ 31 | s3.getObject(params,(err,obj)=>{ 32 | console.log("getObj",bucket,key); 33 | if(err){ 34 | fail(err); 35 | }else{ 36 | console.log("unziping",bucket,key); 37 | zlib.gunzip(obj.Body,function(err,body){ 38 | console.log("unziped",bucket,key); 39 | ok(body.toString()); 40 | }); 41 | } 42 | }); 43 | }) 44 | return p; 45 | }catch(ex){ 46 | console.log(ex); 47 | } 48 | // console.log(data); 49 | // }); 50 | } 51 | }; -------------------------------------------------------------------------------- /views/components/fb/FBComment.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import React from "react"; 4 | import cx from 'classnames'; 5 | import BaseComponent from '../BaseComponent.jsx'; 6 | import Loading from '../Loading.jsx'; 7 | 8 | import fbutil from '../../helpers/fb.jsx'; 9 | 10 | 11 | export default class FBComment extends BaseComponent { 12 | 13 | constructor(props) { 14 | super(props); 15 | } 16 | 17 | componentDidMount(){ 18 | this.initFBComment(); 19 | } 20 | 21 | initFBComment(){ 22 | fbutil.parse(React.findDOMNode(this)).then(()=>{ 23 | this.setState({loaded:true}); 24 | }); 25 | } 26 | 27 | componentWillReceiveProps(nextProps){ 28 | if(this.props.href != nextProps.href ){ 29 | this.setState({loaded:false}); 30 | } 31 | } 32 | 33 | componentDidUpdate(prevProps,prevState){ 34 | if(this.props.href != prevProps.href ){ 35 | this.initFBComment(); 36 | } 37 | } 38 | 39 | render(){ 40 | var b = this.props.item; 41 | return ( 42 |
43 |
46 | 47 |
48 | ); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /model/pg.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | var Promise = require("promise"); 4 | 5 | var pgp = require('pg-promise')(); 6 | 7 | var config = require("../config.js"); 8 | 9 | var db = pgp(config.db); 10 | 11 | var queryResult = { 12 | ONE: 1, // single-row result is expected; 13 | MANY: 2, // multi-row result is expected; 14 | NONE: 4, // no rows expected; 15 | ANY: 6 // (default) = many|none = any result. 16 | }; 17 | 18 | var load = db.connect(); 19 | 20 | var links_cache = {}; 21 | 22 | var p = new Promise((ok,fail)=>{ 23 | load.then(function(con){ 24 | ok([con,queryResult]); 25 | },(err) => { 26 | console.log("err",err,err.stack); 27 | }); 28 | }); 29 | 30 | 31 | 32 | 33 | // var log = function(str){ 34 | // var logs = []; 35 | // for(var i = 0 ; i < arguments.length;++i){ 36 | // logs.push(arguments[i]); 37 | // } 38 | // console.log("[PG]"+logs.join(" ")); 39 | // } 40 | 41 | // for(var k in methods){ 42 | // (function(old,k){ 43 | // methods[k] = function(){ 44 | // // console.log("into methods:",k); 45 | // var that = this; 46 | // var args = arguments; 47 | // return load.then(function(conn){ 48 | // console.log("[PG]connedted:"+k); 49 | // var p = old.apply(that,args); 50 | // if(p.then){ 51 | // p.then(function(){ 52 | // console.log("[PG]closed:"+k); 53 | // },utils._error("[PG]error:"+k)); 54 | // } 55 | // return p; 56 | // }); 57 | // }; 58 | // })(methods[k],k); 59 | 60 | // } 61 | 62 | 63 | export default p; 64 | 65 | 66 | -------------------------------------------------------------------------------- /views/helpers/comment.jsx: -------------------------------------------------------------------------------- 1 | import unitconverter from "./unitconverter.jsx"; 2 | 3 | export default { 4 | refine(comment){ 5 | if(comment == null){ 6 | return ""; 7 | } 8 | comment = comment.replace(/[ ]+/g,""); 9 | var html = "
"+comment+"
"; 10 | html = html.replace(/[0-9]+\./gi, (str) => {return "
"+str }); 11 | html = html.replace(/[一二三四五六七八九十]+、/gi, (str) => {return "
"+str }); 12 | html = html.replace(/\([0-9]+\)/gi, (str) => { return "
   "+str } ); 13 | html = html.replace(/增列/gi, (str) => { return ""+str+"" }); 14 | html = html.replace(/減列/gi, (str) => { return ""+str+"" }); 15 | html = html.replace(/[0-9,]+[ ]?[千]元/gi, this._refine_amount) 16 | html = html.replace(/上年度預算數/gi, (str) => { return ""+str+""} ); 17 | html = html.replace(/<#H([0-9]+)>/gi, (str,num) => {return "
"+(num=="2"?"    ":"") }); 18 | return html; 19 | }, 20 | //TODO:review XSS issue 21 | _refine_amount(str){ 22 | var amount = 0; 23 | if(str.replace){ 24 | amount = parseInt(str.replace(/[,千元]/gi,""),10); 25 | if(str.indexOf("千元") != -1){ 26 | amount = amount * 1000; 27 | } 28 | }else{ 29 | amount = str; 30 | } 31 | 32 | if(amount > 1000000){ 33 | return " " + str + 34 | ( " (約"+ unitconverter.convert(amount,null,false)+") " ) 35 | } 36 | else{ 37 | return " " + str+" " 38 | } 39 | } 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /routes/user.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | import UserModel from '../model/usermodel.jsx'; 5 | 6 | router.get('/signup', function(req, res, next) { 7 | 8 | res.render('dispatch.jsx', 9 | { 10 | comp:'user/signup', 11 | layout:'default', 12 | nav:"home", 13 | pageInfo:{ 14 | title:"預算視覺化產生器 - 註冊" 15 | }, 16 | views:{ 17 | } 18 | }); 19 | }); 20 | 21 | 22 | router.post('/signuping', function(req, res, next) { 23 | var account = req.body.account ; 24 | var pwd = req.body.account ; 25 | 26 | if(account == null || pwd == null || account.trim() =="" || pwd.trim() == ""){ 27 | res.send({ok:false,errorMessage:"帳號或密碼為空"}); 28 | return true; 29 | } 30 | 31 | UserModel.checkAccount(account).then((u)=>{ 32 | if(u != null){ 33 | // req.session._u = u; 34 | res.send({ok:false,errorMessage:"使用者已存在"}); 35 | return true; 36 | } 37 | 38 | 39 | UserModel.insert(account,pwd).then((u)=>{ 40 | res.send({ok:true,u:u,create:true}); 41 | },(err) => { 42 | if(err.code == '23505'){ 43 | res.send({ok:false,errorMessage:"密碼錯誤"}); 44 | return true; 45 | } 46 | res.send({ok:false,errorMessage:"未知的錯誤"}); 47 | 48 | }); 49 | }); 50 | 51 | }); 52 | 53 | 54 | router.get('/login', function(req, res, next) { 55 | 56 | res.render('dispatch.jsx', 57 | { 58 | comp:'user/login', 59 | layout:'default', 60 | nav:"home", 61 | pageInfo:{ 62 | title:"預算視覺化產生器 - 登入" 63 | }, 64 | views:{ 65 | } 66 | }); 67 | 68 | }); 69 | module.exports = router; 70 | -------------------------------------------------------------------------------- /views/controller/radar.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | // import rd3 from 'react-d3'; 5 | 6 | import D3Radar from './../components/d3Radar.jsx'; 7 | 8 | import Util from '../helpers/util'; 9 | 10 | import BaseComponent from './../components/BaseComponent.jsx'; 11 | export default class Bubble extends BaseComponent { 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = {data:[[ 16 | { 17 | className: 'argentina', 18 | axes: [ 19 | {axis: "strength", value: 10}, 20 | {axis: "intelligence", value: 7}, 21 | {axis: "charisma", value: 10}, 22 | {axis: "dexterity", value: 13}, 23 | {axis: "luck", value: 9} 24 | ] 25 | } 26 | ]]}; 27 | 28 | setInterval(()=>{ 29 | this.setState({ 30 | data:[[ 31 | { 32 | className: 'argentina', 33 | axes: [ 34 | {axis: "strength", value: Math.random()*15}, 35 | {axis: "intelligence", value: 7}, 36 | {axis: "charisma", value: 10}, 37 | {axis: "dexterity", value: Math.random()*15}, 38 | {axis: "luck", value: 9} 39 | ] 40 | } 41 | ]] 42 | }) 43 | },2000); 44 | } 45 | 46 | 47 | componentDidMount() { 48 | 49 | } 50 | 51 | componentDidUpdate() { 52 | 53 | } 54 | 55 | 56 | componentWillUnmount() { 57 | } 58 | 59 | 60 | render(){ 61 | //D3Radar 62 | 63 | return ( 64 |
65 | 66 |
67 | ); 68 | } 69 | } 70 | 71 | 72 | if(global.window != null){ 73 | React.render(React.createElement(Bubble,window.react_data), document.getElementById("react-root")); 74 | } 75 | 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "budget", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "dependencies": { 9 | "babel": "^5.8.23", 10 | "babel-core": "^5.4.2", 11 | "babel-loader": "^5.1.0", 12 | "bluebird": "^3.5.0", 13 | "body-parser": "^1.12.4", 14 | "buffer-writer": "^1.0.0", 15 | "classnames": "^2.1.3", 16 | "cookie-parser": "^1.3.5", 17 | "d3": "^3.5.6", 18 | "debug": "^2.1.3", 19 | "excerpt": "0.0.1", 20 | "express": "^4.13.3", 21 | "express-livereload": "0.0.24", 22 | "express-react-views": "^0.10.2", 23 | "express-session": "^1.11.3", 24 | "generic-pool": "^2.2.0", 25 | "http-auth": "^2.2.8", 26 | "jade": "^1.9.2", 27 | "md5": "^2.0.0", 28 | "moment": "^2.10.6", 29 | "morgan": "^1.5.3", 30 | "multer": "^1.1.0", 31 | "packet-reader": "^0.2.0", 32 | "pg": "^4.4.1", 33 | "pg-connection-string": "^0.1.3", 34 | "pg-promise": "^1.9.0", 35 | "pg-types": "^1.10.0", 36 | "promise": "^7.0.1", 37 | "react": "*", 38 | "react-bar-chart": "^0.1.1", 39 | "react-bootstrap": "^0.30.6", 40 | "react-d3": "^0.4.0", 41 | "react-disqus-thread": "^0.4.0", 42 | "react-dom": "^15.3.2", 43 | "react-hot-loader": "^1.2.7", 44 | "react-paginate": "^0.1.29", 45 | "react-proxy-loader": "^0.3.4", 46 | "react-recaptcha": "0.0.3", 47 | "reactify": "*", 48 | "request": "^2.61.0", 49 | "scss-loader": "0.0.1", 50 | "serve-favicon": "~2.3.0", 51 | "session-file-store": "0.0.20", 52 | "sha1": "^1.1.1", 53 | "stats-webpack-plugin": "0.0.1", 54 | "style-loader": "^0.12.2", 55 | "underscore": "^1.8.3", 56 | "url-loader": "^0.5.5", 57 | "vinyl-source-stream": "*", 58 | "watchify": "*" 59 | }, 60 | "devDependencies": { 61 | "css-loader": "^0.19.0", 62 | "node-sass": "^3.3.3", 63 | "sass-loader": "^2.0.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('budget:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /views/components/bootStrapMenu.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var util = require("../helpers/util"); 3 | var cx = require("classnames"); 4 | 5 | var BootStrapMenu = React.createClass({ 6 | render:function(){ 7 | var datas = this.props.items; 8 | 9 | var navs = []; 10 | 11 | var comp = this; 12 | util.each(datas,function(d){ 13 | if(d.childs){ 14 | navs.push( 15 |
  • 16 | 19 | 24 |
  • 25 | ); 26 | }else{ 27 | navs.push( 28 |
  • 29 | {d.label} 30 |
  • 31 | ); 32 | } 33 | }); 34 | 35 | 36 | return ( 37 |
    38 |
    39 | 45 | {this.props.name} 46 |
    47 | 52 |
    53 | ); 54 | } 55 | }); 56 | 57 | module.exports = BootStrapMenu; -------------------------------------------------------------------------------- /views/controller/user/login.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import BaseComponent from './../../components/BaseComponent.jsx'; 4 | // import ReCAPTCHA from "react-google-recaptcha"; 5 | // import ReCAPTCHA from "react-google-recaptcha"; 6 | 7 | import { Input,ButtonInput } from 'react-bootstrap'; 8 | 9 | 10 | export default class Login extends BaseComponent { 11 | constructor(props){ 12 | super(props); 13 | this.state = {}; 14 | } 15 | onSubmit(e){ 16 | var account = this.refs.account.getValue(); 17 | var pwd = this.refs.pwd.getValue(); 18 | 19 | $.post("/user/signuping",{account,pwd},(res) => { 20 | if(res.ok){ 21 | self.location.href = '/user/post'; 22 | return ; 23 | } 24 | this.setState({error:res.errorMessage}); 25 | }); 26 | e.preventDefault(); 27 | } 28 | 29 | render(){ 30 | var errorMessage = this.state.error; 31 | return ( 32 |
    33 |

    註冊或登入新帳號

    34 |
    35 | 36 | 38 | 40 | 41 | {/* this.isClient() &&
    42 | 44 |
    */} 45 | 46 |
    47 | {errorMessage &&
    {errorMessage}
    } 48 | 49 |
    50 | ); 51 | } 52 | 53 | static renderServerScripts(){ 54 | return null; 55 | // return ; 56 | } 57 | } 58 | 59 | 60 | if(global.window != null){ 61 | React.render(React.createElement(Signup,window.react_data), document.getElementById("react-root")); 62 | } 63 | -------------------------------------------------------------------------------- /views/controller/user/signup.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import BaseComponent from './../../components/BaseComponent.jsx'; 4 | // import ReCAPTCHA from "react-google-recaptcha"; 5 | // import ReCAPTCHA from "react-google-recaptcha"; 6 | 7 | import { Input,ButtonInput } from 'react-bootstrap'; 8 | 9 | 10 | export default class Signup extends BaseComponent { 11 | constructor(props){ 12 | super(props); 13 | this.state = {}; 14 | } 15 | onSubmit(e){ 16 | var account = this.refs.account.getValue(); 17 | var pwd = this.refs.pwd.getValue(); 18 | 19 | $.post("/user/signuping",{account,pwd},(res) => { 20 | if(res.ok){ 21 | self.location.href = '/user/post'; 22 | return ; 23 | } 24 | this.setState({error:res.errorMessage}); 25 | }); 26 | e.preventDefault(); 27 | } 28 | 29 | render(){ 30 | var errorMessage = this.state.error; 31 | return ( 32 |
    33 |

    註冊或登入新帳號

    34 |
    35 | 36 | 38 | 40 | 41 | {/* this.isClient() &&
    42 | 44 |
    */} 45 | 46 |
    47 | {errorMessage &&
    {errorMessage}
    } 48 | 49 |
    50 | ); 51 | } 52 | 53 | static renderServerScripts(){ 54 | return null; 55 | // return ; 56 | } 57 | } 58 | 59 | 60 | if(global.window != null){ 61 | React.render(React.createElement(Signup,window.react_data), document.getElementById("react-root")); 62 | } 63 | -------------------------------------------------------------------------------- /views/dispatch.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | var ReactDOMServer = require('react-dom/server'); 4 | 5 | // 6 | // 7 | 8 | 9 | export default class Dispatcher extends React.Component { 10 | render() { 11 | var comp = require("./controller/"+this.props.comp+".jsx").default; 12 | var props = this.props.renders; 13 | if(props == null){ 14 | props = this.props.views; 15 | } 16 | console.log("views",this.props.views); 17 | // var skiped = {"settings":1,"comp":1,"setting":1,"cache":1,"_locals":1,"render":1}; 18 | // for(var k in this.props){ 19 | // if(skiped[k] === 1){ 20 | // continue; 21 | // } 22 | // props[k] = this.props[k]; 23 | // } 24 | 25 | var render = function(comp,props){ 26 | var datas = JSON.stringify(props); 27 | var childs = ReactDOMServer.renderToString(React.createElement(comp,props)) + 28 | " "; 29 | return childs; 30 | }; 31 | 32 | var DefaultLayout = null; 33 | if(this.props.layout == null){ 34 | DefaultLayout = require('./layouts/default.jsx').default; 35 | }else{ 36 | DefaultLayout = require('./layouts/'+this.props.layout+'.jsx').default; 37 | } 38 | 39 | var childs = null; 40 | if(this.props.render){ 41 | childs = this.props.render(comp,props,this.props.views); 42 | }else{ 43 | childs = render(comp,props,this.props.views); 44 | } 45 | var dev = null; 46 | // if(this.props.mode == "develope"){ 47 | // dev = (); 48 | // } 49 | 50 | return ( 51 | 52 |
    53 | {dev} 54 | { /*
    */ } 55 |
    56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /views/components/d3Radar.jsx: -------------------------------------------------------------------------------- 1 | 2 | import d3 from 'd3'; 3 | import _ from 'underscore'; 4 | import React from "react"; 5 | import BaseComponent from './BaseComponent.jsx'; 6 | import unitconverter from "./../helpers/unitconverter.jsx"; 7 | import d3util from "./../helpers/d3.jsx"; 8 | 9 | import Loading from './Loading.jsx'; 10 | 11 | // Reference 12 | // http://www.delimited.io/blog/2013/12/19/force-bubble-charts-in-d3 13 | 14 | // Reference2 15 | // http://bl.ocks.org/mbostock/7882658 16 | 17 | export default class D3Radar extends BaseComponent { 18 | 19 | constructor(props){ 20 | super(props); 21 | this.state={}; 22 | } 23 | 24 | componentDidMount() { 25 | this._draw(); 26 | if(this.props && this.props.data){ 27 | this._update(this.props.data); 28 | } 29 | } 30 | 31 | componentWillReceiveProps(nextProps){ 32 | if(nextProps && nextProps.data){ 33 | this._update(nextProps.data); 34 | } 35 | } 36 | 37 | _draw(){ 38 | var el = React.findDOMNode(this.refs.svg); 39 | var chart = RadarChart.chart(); 40 | this.chart = chart; 41 | var cfg = chart.config(); // retrieve default config 42 | this.cfg = cfg; 43 | 44 | var width = 300; 45 | 46 | if(this.props.width != null){ 47 | width = parseInt(this.props.width,10); 48 | } 49 | 50 | var height = 150; 51 | 52 | if(this.props.height != null){ 53 | height = parseInt(this.props.height,10); 54 | } 55 | 56 | var svg = d3.select(el) 57 | .attr('width', width) 58 | .attr('height', height); 59 | // many radars 60 | chart.config({w: width -100 , h: height-100, 61 | axisText: true, levels: 4, circles: true}); 62 | cfg = chart.config(); 63 | 64 | } 65 | 66 | _update(datas){ 67 | var el = React.findDOMNode(this.refs.svg); 68 | var cfg = this.cfg; 69 | var game = d3.select(el).selectAll('g.game').data(datas); 70 | game.enter().append('g').classed('game', 1); 71 | game.attr('transform', function(d, i) { return 72 | 'translate('+((cfg.w * 4) + 50 + (i * cfg.w))+','+ (cfg.h * 1.3) +')'; }) 73 | .call(this.chart); 74 | } 75 | 76 | render (){ 77 | return ; 78 | } 79 | 80 | 81 | } 82 | 83 | 84 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 2 | require("babel/register"); 3 | 4 | var config = require('./config'); 5 | 6 | var express = require('express'); 7 | var path = require('path'); 8 | var favicon = require('serve-favicon'); 9 | var logger = require('morgan'); 10 | var cookieParser = require('cookie-parser'); 11 | var bodyParser = require('body-parser'); 12 | 13 | var routes = require('./routes/index'); 14 | 15 | var app = express(); 16 | 17 | 18 | // livereload = require('express-livereload'); 19 | // livereload(app, {watchDir:process.cwd() + "/views"}); 20 | 21 | // view engine setup 22 | app.set('views', path.join(__dirname, 'views')); 23 | // app.set('view engine', 'jade'); 24 | app.set('view engine', 'jsx'); 25 | 26 | var options = { beautify: false }; 27 | app.engine('jsx', require('express-react-views').createEngine(options)); 28 | app.engine('js', require('express-react-views').createEngine(options)); 29 | 30 | 31 | 32 | var session = require('express-session'); 33 | var FileStore = require('session-file-store')(session); 34 | 35 | app.use(session({ 36 | store: new FileStore(options), 37 | secret: config.session_secret 38 | })); 39 | 40 | 41 | // uncomment after placing your favicon in /public 42 | //app.use(favicon(__dirname + '/public/favicon.ico')); 43 | app.use(logger('dev')); 44 | app.use(bodyParser.json()); 45 | app.use(bodyParser.urlencoded({ extended: true })); 46 | app.use(cookieParser()); 47 | app.use(express.static(path.join(__dirname, 'public'))); 48 | 49 | app.use('/', routes); 50 | if(!config.file_model){ 51 | app.use('/user', require('./routes/user')); 52 | } 53 | 54 | // catch 404 and forward to error handler 55 | app.use(function(req, res, next) { 56 | var err = new Error('Not Found'); 57 | err.status = 404; 58 | next(err); 59 | }); 60 | 61 | // error handlers 62 | 63 | // development error handler 64 | // will print stacktrace 65 | if (app.get('env') === 'development') { 66 | app.use(function(err, req, res, next) { 67 | res.status(err.status || 500); 68 | res.render('error', { 69 | message: err.message, 70 | error: err 71 | }); 72 | }); 73 | } 74 | 75 | // production error handler 76 | // no stacktraces leaked to user 77 | app.use(function(err, req, res, next) { 78 | res.status(err.status || 500); 79 | res.render('error', { 80 | message: err.message, 81 | error: {} 82 | }); 83 | }); 84 | 85 | 86 | module.exports = app; 87 | -------------------------------------------------------------------------------- /views/helpers/d3.jsx: -------------------------------------------------------------------------------- 1 | import d3 from 'd3'; 2 | 3 | export default { 4 | getChangeColorScale(){ 5 | 6 | // var colors = ['black','#C51B7D', '#DE77AE', '#F1B6DA', '#FDE0EF', 'gray', '#B8E186', '#7FBC41', '#4D9221']; 7 | var color_domain = [-1, -0.99, -0.25, -0.1, -0.02, 0, 0.02, 0.1, 0.25]; 8 | var colors = ["black", "#C51B7D", "#C51B7D", '#DE77AE', '#F1B6DA', '#FDE0EF', "#E6F5D0", "#B8E186" , "#7FBC41", '#4D9221']; 9 | 10 | return d3.scale.quantile().domain(color_domain) 11 | .range(colors); 12 | 13 | }, 14 | _drawScaleReference(svg){ 15 | 16 | // var colors = ['black','#C51B7D', '#DE77AE', '#F1B6DA', '#FDE0EF', 'gray', '#B8E186', '#7FBC41', '#4D9221']; 17 | var color_domain = [-1, -0.25, -0.1, -0.02, 0, 0.02, 0.1, 0.25]; 18 | var colors = ["black", "#C51B7D", '#DE77AE', '#F1B6DA', '#FDE0EF', "#E6F5D0", "#B8E186" , "#7FBC41", '#4D9221']; 19 | 20 | 21 | // var colorScale = this.getChangeColorScale(); 22 | var colorScale = d3.scale.quantile().domain(color_domain) 23 | .range(colors); 24 | 25 | var xColorScale = d3.scale.ordinal().rangeRoundBands([200, 0], 0.1).domain(colorScale.domain()); 26 | // var yColorScale = d3.scale.ordinal().rangeRoundBands([200, 0], 0.1).domain(colors); 27 | 28 | var marign ={left: 10,top:0}; 29 | 30 | var nodes = svg.selectAll('.change-lenged').data(colorScale.domain()); 31 | nodes.enter().append('rect').attr('class', 'change-legend').attr('x', function(it){ 32 | return marign.left + xColorScale(it) * 1.55; 33 | }).attr('y', 20).attr('width', function(){ 34 | return xColorScale.rangeBand(); 35 | }).attr('height', function(){ 36 | return 30; 37 | }).style('fill', function(it){ 38 | if(isNaN(it)){ 39 | return "black"; 40 | } 41 | return colorScale(it); 42 | }).attr('stroke', 'none'); 43 | nodes.enter().append('text').text(function(it){ 44 | 45 | if(it == -1){ 46 | return "刪除"; 47 | } 48 | return (it * 100) +"%"; 49 | 50 | }).attr('x', function(it){ 51 | return (marign.left + 5 - this.getComputedTextLength()) + (isNaN(it) 52 | ? xColorScale.rangeBand() / 2 53 | : xColorScale.rangeBand()) + xColorScale(it) * 1.55; 54 | }).attr('y', 15).attr('text-anchor', 'right'); 55 | 56 | return colorScale; 57 | } 58 | }; -------------------------------------------------------------------------------- /views/components/PaginationListView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //https://github.com/AdeleD/react-paginate 3 | 4 | var _ = require('underscore'); 5 | var React = require('react/addons'); 6 | var PageView = require('./PageView'); 7 | 8 | var PaginationListView = React.createClass({ 9 | displayName: 'PaginationListView', 10 | 11 | render: function render() { 12 | var items = {}; 13 | 14 | if (this.props.pageNum <= this.props.pageRangeDisplayed) { 15 | 16 | var pageViews = _.range(0, this.props.pageNum).map((function (page) { 17 | return React.createElement(PageView, { 18 | onClick: this.props.onPageSelected.bind(null, page), 19 | selected: this.props.selected === page, 20 | activeClass: this.props.activeClass, 21 | href:this.props.pageLinkGenerator(page +1), 22 | page: page + 1 }); 23 | }).bind(this)); 24 | 25 | pageViews.forEach(function (pageView, index) { 26 | items[String(index)] = pageView; 27 | }); 28 | } else { 29 | 30 | var leftSide = this.props.pageRangeDisplayed / 2; 31 | var rightSide = this.props.pageRangeDisplayed - leftSide; 32 | 33 | if (this.props.selected > this.props.pageNum - this.props.pageRangeDisplayed / 2) { 34 | rightSide = this.props.pageNum - this.props.selected; 35 | leftSide = this.props.pageRangeDisplayed - rightSide; 36 | } else if (this.props.selected < this.props.pageRangeDisplayed / 2) { 37 | leftSide = this.props.selected; 38 | rightSide = this.props.pageRangeDisplayed - leftSide; 39 | } 40 | 41 | var index; 42 | var page; 43 | 44 | for (index = 0; index < this.props.pageNum; index++) { 45 | 46 | page = index + 1; 47 | 48 | var pageView = React.createElement(PageView, { 49 | onClick: this.props.onPageSelected.bind(null, index), 50 | selected: this.props.selected === index, 51 | activeClass: this.props.activeClass, 52 | href:this.props.pageLinkGenerator(index + 1), 53 | page: index + 1 }); 54 | 55 | if (page <= this.props.marginPagesDisplayed) { 56 | items[String(index)] = pageView; 57 | continue; 58 | } 59 | 60 | if (page > this.props.pageNum - this.props.marginPagesDisplayed) { 61 | items[String(index)] = pageView; 62 | continue; 63 | } 64 | 65 | if (page >= this.props.selected - leftSide && index <= this.props.selected + rightSide) { 66 | items[String(index)] = pageView; 67 | continue; 68 | } 69 | 70 | var keys = Object.keys(items); 71 | var breakLabelKey = keys[keys.length - 1]; 72 | var breakLabelValue = items[breakLabelKey]; 73 | 74 | if (breakLabelValue !== this.props.breakLabel) { 75 | items[String(index)] = this.props.breakLabel; 76 | } 77 | } 78 | } 79 | 80 | return React.createElement( 81 | 'ul', 82 | { className: this.props.subContainerClassName }, 83 | React.addons.createFragment(items) 84 | ); 85 | } 86 | }); 87 | 88 | module.exports = PaginationListView; -------------------------------------------------------------------------------- /views/layouts/default.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import util from "../helpers/util"; 3 | import cx from "classnames"; 4 | var {asset_url} = util; 5 | 6 | import BootStrapMenu from "../components/bootStrapMenu.jsx"; 7 | 8 | export default class FrontLayout extends React.Component { 9 | render() { 10 | var GA = " (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ "+ 11 | " (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), "+ 12 | " m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) "+ 13 | " })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); "+ 14 | " "+ 15 | " ga('create', 'UA-67262972-1', 'auto'); "+ 16 | " ga('send', 'pageview'); "+ 17 | " "+ 18 | " ga('create', 'UA-67265163-1', 'auto', {'name': 'newTracker'}); "+ 19 | " ga('newTracker.send', 'pageview'); "; 20 | 21 | 22 | var items = [ 23 | { 24 | key:"/upload", 25 | url:"/upload", 26 | label:"上傳新的預算書" 27 | } 28 | ]; 29 | 30 | return ( 31 | 32 | 33 | 34 | {this.props.title} 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
    47 | 52 | {this.props.children} 53 |
    54 |
    55 |
    56 |

    Powered by TonyQ , inpsired from g0v 中央政府總預算系統, welcome to fork or contribute on the Github projects. 57 |

    58 |
    59 |
    60 |
    61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {this.props.scripts} 70 | 71 | 72 | ); 73 | } 74 | } 75 | 76 | 77 | -------------------------------------------------------------------------------- /views/helpers/unitconverter.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | class UnitMapper{ 4 | 5 | constructor(){ 6 | this.unit = 0; 7 | this.callbacks = []; 8 | this.table = [ 9 | ["", '元', 1], 10 | ['份', '營養午餐', '25'], ['人的', '年薪', '308000'], 11 | ['分鐘', '太空旅遊', '1000000'], ['碗', '鬍鬚張魯肉飯', '68'], 12 | ['個', '便當', '50'], 13 | ['杯', '珍奶', '30'], 14 | ['份', '雞排加珍奶', '60'], 15 | ['座', '冰島', '2000080000000'], 16 | ['支', 'iPhone5', '25900'], 17 | ['次', '北市重陽敬老禮金', '770000000'] 18 | ]; 19 | } 20 | 21 | random(){ 22 | return this.unit = parseInt(Math.random() * this.table.length); 23 | } 24 | 25 | get(){ 26 | return this.unit; 27 | } 28 | 29 | getUnit(des_unit){ 30 | des_unit == null && (des_unit = this.unit); 31 | return this.table[des_unit][1]; 32 | } 33 | 34 | getQuantifier(des_unit){ 35 | des_unit == null && (des_unit = this.unit); 36 | return this.table[des_unit][0]; 37 | } 38 | 39 | _num(val, divide, floats){ 40 | // console.log(((val / divide * Math.pow(10, 2))),Math.round(val / divide * Math.pow(10, 2))); 41 | return parseInt(Math.round(val / divide * Math.pow(10, 2)), 10) / Math.pow(10, floats); 42 | } 43 | 44 | convert(value2, des_unit, full_desc){ 45 | var prefix, value, unitdata; 46 | prefix = ""; 47 | if (value2 < 0) { 48 | value = value2 * -1; 49 | prefix = "-"; 50 | } else { 51 | value = value2; 52 | } 53 | if(des_unit == null){ 54 | des_unit = 0 ; 55 | } 56 | if (des_unit === -1) { 57 | des_unit = parseInt(Math.random() * this.table.length); 58 | } 59 | des_unit == null && (des_unit = this.unit); 60 | unitdata = this.table[des_unit] || ["", "元", 1]; 61 | value = parseInt(10000 * value / unitdata[2]) / 10000; 62 | value = value >= 1000000000000 63 | ? this._num(value, 1000000000000, 2) + " 兆" 64 | : value >= 100000000 65 | ? this._num(value, 100000000, 2) + " 億" 66 | : value >= 10000 67 | ? parseInt(value / 10000) + " 萬" 68 | : value >= 1000 69 | ? parseInt(value / 1000) + " 千" 70 | : value >= 1 ? parseInt(10 * value) / 10 : value; 71 | return prefix + value + (full_desc ? unitdata[0] + unitdata[1] : ""); 72 | } 73 | 74 | onchange(func){ 75 | return this.callbacks.push(func); 76 | } 77 | 78 | percent(part,sum){ 79 | if(sum == 0){ 80 | return "100%"; 81 | } 82 | 83 | var p = (parseInt( (part/sum) *100 * 100,10)/100); 84 | 85 | return (p > 0 ? "+"+p:p)+"%"; 86 | } 87 | 88 | _percent(part,sum){ 89 | if(sum == 0){ 90 | return 1; 91 | } 92 | return (part/sum); 93 | } 94 | 95 | update(idx){ 96 | if (this.unit >= 0) { 97 | $('#unit-selector li:eq(' + this.unit + ') ').removeClass('active'); 98 | } 99 | this.unit = idx === -1 100 | ? parseInt(Math.random() * this.table.length) 101 | : idx === void 8 ? 0 : idx; 102 | $('#unit-selector li:eq(' + this.unit + ')').addClass('active'); 103 | d3.selectAll('text.amount').text((d) =>{ 104 | return this.convert(d.size || d.value.sum, this.unit, true); 105 | }); 106 | 107 | var that = this; 108 | jQuery.each($(".unit-convert"), function(){ 109 | return $(this).text(that.convert($(this).attr("cc-value"), that.unit, true)); 110 | }); 111 | return jQuery.each(this.callbacks, function(x){ 112 | return this(); 113 | }); 114 | } 115 | 116 | init(){} 117 | } 118 | 119 | export default new UnitMapper(); 120 | 121 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // module.exports = require("./webpack_inner")({ 2 | // devServer: true, 3 | // devtool: "eval", 4 | // debug: true 5 | // }); 6 | var devServer = false; 7 | var hotComponents = false; 8 | var hot = false; 9 | 10 | var path = require("path"); 11 | 12 | 13 | var fs = require("fs"); 14 | 15 | function scan(dir){ 16 | var files = fs.readdirSync(dir); 17 | var res = []; 18 | files.forEach(function(f){ 19 | if(f.indexOf("js") != -1 || f.indexOf("jsx") != -1){ 20 | res.push(dir +"/"+ f); 21 | }else{ 22 | try{ 23 | var child = scan(dir +"/"+ f); 24 | [].push.apply(res,child); 25 | }catch(ex){ 26 | // console.log(f,ex); 27 | } 28 | } 29 | }); 30 | return res; 31 | } 32 | 33 | var entries = {}; 34 | var views = scan("views"); 35 | views.forEach(function(v){ 36 | if(hot){ 37 | entries[v.replace("views/","").replace(/\.js[x]?/,"")] = ['webpack/hot/dev-server',"./"+v]; 38 | }else{ 39 | entries[v.replace("views/","").replace(/\.js[x]?/,"")] = ["./"+v]; 40 | } 41 | }); 42 | 43 | 44 | function extsToRegExp(exts) { 45 | return new RegExp("\\.(" + exts.map(function(ext) { 46 | return ext.replace(/\./g, "\\."); 47 | }).join("|") + ")(\\?.*)?$"); 48 | } 49 | 50 | var loadersByExtension = function(obj) { 51 | var loaders = []; 52 | Object.keys(obj).forEach(function(key) { 53 | var exts = key.split("|"); 54 | var value = obj[key]; 55 | var entry = { 56 | extensions: exts, 57 | test: extsToRegExp(exts), 58 | loaders: value 59 | }; 60 | if(Array.isArray(value)) { 61 | entry.loaders = value; 62 | } else if(typeof value === "string") { 63 | entry.loader = value; 64 | } else { 65 | Object.keys(value).forEach(function(valueKey) { 66 | entry[valueKey] = value[valueKey]; 67 | }); 68 | } 69 | loaders.push(entry); 70 | }); 71 | return loaders; 72 | }; 73 | 74 | var loaders = { 75 | "js|jsx": hotComponents ? ["react-hot-loader", "babel-loader?stage=0"] : "babel-loader?stage=0", 76 | // "js": { 77 | // loader: "babel-loader?stage=0", 78 | // include: path.join(__dirname, "app") 79 | // }, 80 | "json": "json-loader", 81 | "json5": "json5-loader", 82 | "txt": "raw-loader", 83 | "png|jpg|jpeg|gif|svg": "url-loader?limit=10000", 84 | "woff|woff2": "url-loader?limit=100000", 85 | "ttf|eot": "file-loader", 86 | "wav|mp3": "file-loader", 87 | "less":"style!css!less?sourceMap", 88 | "scss":"style!css!sass?sourceMap", 89 | "html": "html-loader" 90 | // "md|markdown": ["html-loader", "markdown-loader"] 91 | }; 92 | 93 | var publicPath = devServer ? 94 | "http://localhost:9091/resource/" : 95 | "/resource/"; 96 | var output = { 97 | path: path.join(__dirname, "public/resource"), 98 | publicPath: publicPath, 99 | filename: "[name].js" , 100 | // chunkFilename: (devServer ? "[id].js" : "[name].js") , 101 | //+ (options.longTermCaching && !options.prerender ? "?[chunkhash]" : ""), 102 | sourceMapFilename: "debugging/[file].map" 103 | // libraryTarget: "commonjs" 104 | // pathinfo: true 105 | }; 106 | 107 | module.exports = { 108 | entry:entries, 109 | // exclude:["react","react/addons"], 110 | module:{ 111 | loaders:loadersByExtension(loaders) 112 | }, 113 | resolveLoader: { 114 | root: path.join(__dirname, 'node_modules') 115 | }, 116 | output:output, 117 | externals:[ 118 | { 119 | "react": "var React", 120 | "react/addons": "var React", 121 | "d3":"var d3", 122 | } 123 | ], 124 | devServer: { 125 | proxy: { 126 | '*': 'http://localhost:3000' 127 | } 128 | } 129 | // target: "web" 130 | } 131 | -------------------------------------------------------------------------------- /views/layouts/front.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import util from "../helpers/util"; 3 | import cx from "classnames"; 4 | var {asset_url} = util; 5 | 6 | import BootStrapMenu from "../components/bootStrapMenu.jsx"; 7 | 8 | export default class FrontLayout extends React.Component { 9 | render() { 10 | var GA = " (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ "+ 11 | " (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), "+ 12 | " m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) "+ 13 | " })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); "+ 14 | " "+ 15 | " ga('create', 'UA-67262972-1', 'auto'); "+ 16 | " ga('send', 'pageview'); "+ 17 | " "+ 18 | " ga('create', 'UA-67265163-1', 'auto', {'name': 'newTracker'}); "+ 19 | " ga('newTracker.send', 'pageview'); "; 20 | 21 | var items = [ 22 | { 23 | key:"/drilldown/", 24 | url:"/drilldown/"+this.props.pageInfo.id, 25 | label:"鳥瞰圖" 26 | }, 27 | { 28 | key:"/bubble/", 29 | url:"/bubble/"+this.props.pageInfo.id, 30 | label:"變化圖" 31 | }, 32 | { 33 | key:"/table/", 34 | url:"/table/"+this.props.pageInfo.id, 35 | label:"科目預算表格" 36 | }, 37 | { 38 | key:"/", 39 | url:"/", 40 | label:"回所有預算視覺化清單" 41 | } 42 | ]; 43 | 44 | return ( 45 | 46 | 47 | 48 | {this.props.title} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
    63 | 68 | {this.props.children} 69 |
    70 |
    71 |
    72 |

    Powered by TonyQ , inpsired from g0v 中央政府總預算系統, welcome to fork or contribute on the Github projects.

    73 |
    74 |
    75 |
    76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | } 88 | } 89 | 90 | 91 | -------------------------------------------------------------------------------- /views/controller/table.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | // import rd3 from 'react-d3'; 5 | 6 | import BudgetTable from './../components/BudgetTable.jsx'; 7 | import BudgetGroupTable from './../components/BudgetGroupTable.jsx'; 8 | 9 | import BaseComponent from './../components/BaseComponent.jsx'; 10 | import cx from 'classnames'; 11 | import CommentHelper from './../helpers/comment.jsx'; 12 | 13 | import Util from '../helpers/util'; 14 | import Promise from "bluebird"; 15 | 16 | export default class TableView extends BaseComponent { 17 | 18 | constructor(props) { 19 | super(props); 20 | 21 | if(global.window != null){ 22 | Promise.all( 23 | [ 24 | Util.getBudgetInfos( 25 | this.props.budget_file_type, 26 | this.props.budget_links), 27 | Util.process_meta_link(this.props.budget_meta_links) 28 | ]).then(([res,meta])=>{ 29 | this.setState({ 30 | last_budget:res, 31 | waiting:false, 32 | codeMetas:meta 33 | }); 34 | }); 35 | } 36 | 37 | this.state = { 38 | budget_links:this.props.budget_links, 39 | last_budget:this.props.last_budget, 40 | budgets:this.props.budgets, 41 | waiting:true, 42 | _subnav:props._subnav 43 | }; 44 | } 45 | 46 | doNav(nav){ 47 | this.setState({_subnav:nav}); 48 | this.setUrl("/table/"+this.props.budget_id+"/"+nav,window.title); 49 | return false; 50 | } 51 | 52 | 53 | render(){ 54 | var {last_budget,codeMetas} = this.state; 55 | // var {drilldown} = data; 56 | 57 | var keysMap = { 58 | topname:{ 59 | topname:{name:"款別"} 60 | }, 61 | depname:{ 62 | topname:{name:"款別"}, 63 | depname:{name:"項別"} 64 | }, 65 | category:{ 66 | topname:{name:"款別"}, 67 | depname:{name:"項別"}, 68 | category:{name:"目別"} 69 | } 70 | }; 71 | 72 | return ( 73 |
    74 | 88 | {this.state._subnav == 'all' &&
    89 |
    90 | 91 |
    } 92 | { 93 | this.state._subnav != 'all' &&
    94 |
    95 |
    96 | 97 |
    98 |
    99 | } 100 |
    101 | ); 102 | } 103 | } 104 | 105 | 106 | if(global.window != null){ 107 | React.render(React.createElement(TableView,window.react_data), document.getElementById("react-root")); 108 | } 109 | 110 | -------------------------------------------------------------------------------- /views/components/PaginationBoxView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //https://github.com/AdeleD/react-paginate 3 | 4 | var React = require('react'); 5 | var classNames = require('classnames'); 6 | var PaginationListView = require('./PaginationListView'); 7 | 8 | var PaginationBoxView = React.createClass({ 9 | displayName: 'PaginationBoxView', 10 | 11 | propTypes: { 12 | pageNum: React.PropTypes.number.isRequired, 13 | pageRangeDisplayed: React.PropTypes.number.isRequired, 14 | marginPagesDisplayed: React.PropTypes.number.isRequired, 15 | previousLabel: React.PropTypes.node, 16 | nextLabel: React.PropTypes.node, 17 | breakLabel: React.PropTypes.node, 18 | clickCallback: React.PropTypes.func, 19 | initialSelected: React.PropTypes.number, 20 | forceSelected: React.PropTypes.number, 21 | containerClassName: React.PropTypes.string, 22 | subContainerClassName: React.PropTypes.string, 23 | activeClass: React.PropTypes.string 24 | }, 25 | 26 | getDefaultProps: function getDefaultProps() { 27 | return { 28 | pageNum: 10, 29 | pageRangeDisplayed: 2, 30 | marginPagesDisplayed: 3, 31 | previousLabel: 'Previous', 32 | nextLabel: 'Next', 33 | breakLabel: '...', 34 | pageLinkGenerator:function(){ 35 | return ''; 36 | } 37 | }; 38 | }, 39 | 40 | getInitialState: function getInitialState() { 41 | return { 42 | selected: this.props.initialSelected ? this.props.initialSelected : 0 43 | }; 44 | }, 45 | 46 | handlePageSelected: function handlePageSelected(selected, event) { 47 | event.preventDefault(); 48 | 49 | if (this.state.selected === selected) { 50 | return; 51 | } 52 | this.setState({ selected: selected }); 53 | 54 | if (typeof this.props.clickCallback !== 'undefined' && typeof this.props.clickCallback === 'function') { 55 | this.props.clickCallback({ selected: selected }); 56 | } 57 | }, 58 | 59 | handlePreviousPage: function handlePreviousPage(event) { 60 | event.preventDefault(); 61 | if (this.state.selected > 0) { 62 | this.handlePageSelected(this.state.selected - 1, event); 63 | } 64 | }, 65 | 66 | handleNextPage: function handleNextPage(event) { 67 | event.preventDefault(); 68 | if (this.state.selected < this.props.pageNum - 1) { 69 | this.handlePageSelected(this.state.selected + 1, event); 70 | } 71 | }, 72 | 73 | render: function render() { 74 | var previousClasses = classNames({ 75 | previous: true, 76 | disabled: this.state.selected === 0 77 | }); 78 | 79 | var nextClasses = classNames({ 80 | next: true, 81 | disabled: this.state.selected === this.props.pageNum - 1 82 | }); 83 | 84 | var prev = this.state.selected; 85 | if(prev < 1 ){ 86 | prev =1; 87 | } 88 | var prevurl = this.props.pageLinkGenerator(prev); 89 | 90 | var nextpage = this.state.selected + 2; 91 | if(nextpage > this.props.pageNum){ 92 | nextpage = this.props.pageNum; 93 | } 94 | var nexturl = this.props.pageLinkGenerator(nextpage); 95 | 96 | return React.createElement( 97 | 'ul', 98 | { className: this.props.containerClassName }, 99 | React.createElement( 100 | 'li', 101 | { onClick: this.handlePreviousPage, className: previousClasses }, 102 | React.createElement( 103 | 'a', 104 | { href: prevurl }, 105 | this.props.previousLabel 106 | ) 107 | ), 108 | React.createElement(PaginationListView, { 109 | onPageSelected: this.handlePageSelected, 110 | selected: this.state.selected, 111 | pageNum: this.props.pageNum, 112 | pageLinkGenerator:this.props.pageLinkGenerator, 113 | pageRangeDisplayed: this.props.pageRangeDisplayed, 114 | marginPagesDisplayed: this.props.marginPagesDisplayed, 115 | breakLabel: this.props.breakLabel, 116 | subContainerClassName: this.props.subContainerClassName, 117 | activeClass: this.props.activeClass }), 118 | React.createElement( 119 | 'li', 120 | { onClick: this.handleNextPage, className: nextClasses }, 121 | React.createElement( 122 | 'a', 123 | { href: nexturl }, 124 | this.props.nextLabel 125 | ) 126 | ) 127 | ); 128 | }, 129 | 130 | componentWillReceiveProps: function componentWillReceiveProps(nextProps) { 131 | if (typeof nextProps.forceSelected !== 'undefined' && nextProps.forceSelected !== this.state.selected) { 132 | this.setState({ selected: nextProps.forceSelected }); 133 | } 134 | } 135 | }); 136 | 137 | module.exports = PaginationBoxView; -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 2 | import Promise from 'promise'; 3 | var express = require('express'); 4 | var router = express.Router(); 5 | 6 | import Config from "../config"; 7 | 8 | 9 | var BudgetModel = null,_BudgetModel= null,_BudgetFileModel = null; 10 | if(!Config.file_model){ 11 | _BudgetModel = require('../model/budgetmodel.jsx'); 12 | }else{ 13 | _BudgetFileModel = require('../model/budgetfilemodel.jsx'); 14 | } 15 | BudgetModel = Config.file_model ? _BudgetFileModel : _BudgetModel; 16 | 17 | import config from "../config.js"; 18 | 19 | var fs = require("fs"); 20 | 21 | var multer = require('multer'); 22 | var upload = multer({ dest: '/tmp/' }); 23 | 24 | 25 | /* GET home page. */ 26 | router.get('/', function(req, res, next) { 27 | 28 | BudgetModel.getAll(1,1000).then(function(budgets){ 29 | res.render('dispatch.jsx', 30 | { 31 | comp:'index', 32 | layout:'default', 33 | nav:"home", 34 | pageInfo:{ 35 | title:"預算視覺化產生器", 36 | "ogimage":"", 37 | description:"迅速產生預算視覺化", 38 | }, 39 | views:{ 40 | default_view:Config.default_view=="drilldown" ? "drilldown":"bubble", 41 | budgets:budgets 42 | } 43 | }); 44 | }) 45 | 46 | }); 47 | 48 | 49 | router.get('/drilldown/:id', function(req, res, next) { 50 | var budget = req.params.id; 51 | BudgetModel.get(budget).then(function(data){ 52 | res.render('dispatch.jsx', 53 | { 54 | comp:'drilldown', 55 | layout:'front', 56 | nav:"home", 57 | budget_id:budget, 58 | pageInfo:data, 59 | views:{ 60 | budget_links:data.budgets, 61 | budget_id:data.id, 62 | budget_file_type:data.budget_file_type, 63 | budget_meta_links:data.meta_links 64 | } 65 | }); 66 | }); 67 | }); 68 | 69 | 70 | router.get('/bubble/:id', function(req, res, next) { 71 | var budget = req.params.id; 72 | BudgetModel.get(budget).then(function(data){ 73 | 74 | res.render('dispatch.jsx', 75 | { 76 | comp: data.budget_file_type == "2" ? 'bubble-gov': 'bubble', 77 | layout:'front', 78 | nav:"home", 79 | budget_id:budget, 80 | pageInfo:data, 81 | views:{ 82 | budget_links:data.budgets, 83 | budget_id:data.id, 84 | budget_file_type:data.budget_file_type, 85 | budget_meta_links:data.meta_links 86 | 87 | } 88 | }); 89 | }); 90 | }); 91 | 92 | 93 | router.get('/bubble-test', function(req, res, next) { 94 | var budget = req.query.file; 95 | var budget_type = req.query.type || 0; 96 | 97 | res.render('dispatch.jsx', 98 | { 99 | comp:'bubble', 100 | layout:'front', 101 | nav:"home", 102 | budget_id:-1, 103 | pageInfo:{}, 104 | views:{ 105 | budget_links:[budget], 106 | budget_id:-1, 107 | budget_file_type:budget_type 108 | } 109 | }); 110 | }); 111 | 112 | 113 | router.get('/radar-test', function(req, res, next) { 114 | 115 | res.render('dispatch.jsx', 116 | { 117 | comp:'radar', 118 | layout:'front', 119 | nav:"home", 120 | budget_id:-1, 121 | pageInfo:{}, 122 | views:{ 123 | } 124 | }); 125 | }); 126 | 127 | 128 | router.get('/table/:id/:type?', function(req, res, next) { 129 | var budget = req.params.id; 130 | // console.log("type",req.params.type); 131 | 132 | var allowType = {'all':1,'topname':1,'depname':1,'category':1}; 133 | if(req.params.type != null && allowType[req.params.type] == null){ 134 | return next(); 135 | } 136 | 137 | BudgetModel.get(budget).then(function(data){ 138 | res.render('dispatch.jsx', 139 | { 140 | comp:'table', 141 | layout:'front', 142 | nav:"home", 143 | pageInfo:data, 144 | views:{ 145 | _subnav:req.params.type || 'all', 146 | budget_links:data.budgets, 147 | budget_id:data.id, 148 | budget_file_type:data.budget_file_type, 149 | budget_meta_links:data.meta_links 150 | } 151 | }); 152 | }); 153 | }); 154 | 155 | 156 | 157 | 158 | router.get('/upload', function(req, res, next) { 159 | res.render('dispatch.jsx', 160 | { 161 | comp:'upload', 162 | layout:'default', 163 | nav:"upload", 164 | pageInfo:{ 165 | title:"預算視覺化平台" 166 | }, 167 | views:{ 168 | } 169 | }); 170 | 171 | }); 172 | 173 | 174 | 175 | router.post('/uploading', upload.single('file'), function(req, res, next) { 176 | // console.log(req.file); 177 | // { fieldname: 'file', 178 | // originalname: 'testbudget.csv', 179 | // encoding: '7bit', 180 | // mimetype: 'text/csv', 181 | // destination: '/tmp/', 182 | // filename: '50409340425fbf2c839cfbd03da84463', 183 | // path: '/tmp/50409340425fbf2c839cfbd03da84463', 184 | // size: 38961 } 185 | var content = fs.readFileSync(req.file.path).toString(); 186 | 187 | // console.log(content); 188 | 189 | }); 190 | 191 | 192 | module.exports = router; 193 | 194 | 195 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .hidden{ 4 | display:none; 5 | } 6 | 7 | .cp-loading{ 8 | opacity:0.5; 9 | } 10 | 11 | .div{ 12 | color:red; 13 | } 14 | 15 | .budget-amount-title{ 16 | font-size: 36px; 17 | line-height: 30px; 18 | text-shadow: #999 3px 3px 2px; 19 | } 20 | 21 | 22 | /* chart */ 23 | 24 | 25 | #chart {width: 960px; height: 500px; background: #ddd; } 26 | text {pointer-events: none; } 27 | .grandparent text {font-weight: bold; } 28 | rect {fill: none; stroke: #fff; } 29 | rect.parent, .grandparent rect {stroke-width: 2px; } 30 | .grandparent rect {fill: orange; } 31 | .grandparent:hover rect {fill: #ee9700; } 32 | .children rect.parent, .grandparent rect {cursor: pointer; } 33 | .children rect.parent,g > rect.parent {fill: #bbb; fill-opacity: .5; } 34 | .children:hover rect.child {fill: #bbb; } 35 | 36 | .budget-circle{ 37 | cursor:pointer; 38 | } 39 | 40 | 41 | /* loading */ 42 | .sk-circle { 43 | margin: 100px auto; 44 | width: 40px; 45 | height: 40px; 46 | position: relative; 47 | } 48 | .sk-circle .sk-child { 49 | width: 100%; 50 | height: 100%; 51 | position: absolute; 52 | left: 0; 53 | top: 0; 54 | } 55 | .sk-circle .sk-child:before { 56 | content: ''; 57 | display: block; 58 | margin: 0 auto; 59 | width: 15%; 60 | height: 15%; 61 | background-color: #333; 62 | border-radius: 100%; 63 | -webkit-animation: sk-circleBounceDelay 1.2s infinite ease-in-out both; 64 | animation: sk-circleBounceDelay 1.2s infinite ease-in-out both; 65 | } 66 | .sk-circle .sk-circle2 { 67 | -webkit-transform: rotate(30deg); 68 | -ms-transform: rotate(30deg); 69 | transform: rotate(30deg); } 70 | .sk-circle .sk-circle3 { 71 | -webkit-transform: rotate(60deg); 72 | -ms-transform: rotate(60deg); 73 | transform: rotate(60deg); } 74 | .sk-circle .sk-circle4 { 75 | -webkit-transform: rotate(90deg); 76 | -ms-transform: rotate(90deg); 77 | transform: rotate(90deg); } 78 | .sk-circle .sk-circle5 { 79 | -webkit-transform: rotate(120deg); 80 | -ms-transform: rotate(120deg); 81 | transform: rotate(120deg); } 82 | .sk-circle .sk-circle6 { 83 | -webkit-transform: rotate(150deg); 84 | -ms-transform: rotate(150deg); 85 | transform: rotate(150deg); } 86 | .sk-circle .sk-circle7 { 87 | -webkit-transform: rotate(180deg); 88 | -ms-transform: rotate(180deg); 89 | transform: rotate(180deg); } 90 | .sk-circle .sk-circle8 { 91 | -webkit-transform: rotate(210deg); 92 | -ms-transform: rotate(210deg); 93 | transform: rotate(210deg); } 94 | .sk-circle .sk-circle9 { 95 | -webkit-transform: rotate(240deg); 96 | -ms-transform: rotate(240deg); 97 | transform: rotate(240deg); } 98 | .sk-circle .sk-circle10 { 99 | -webkit-transform: rotate(270deg); 100 | -ms-transform: rotate(270deg); 101 | transform: rotate(270deg); } 102 | .sk-circle .sk-circle11 { 103 | -webkit-transform: rotate(300deg); 104 | -ms-transform: rotate(300deg); 105 | transform: rotate(300deg); } 106 | .sk-circle .sk-circle12 { 107 | -webkit-transform: rotate(330deg); 108 | -ms-transform: rotate(330deg); 109 | transform: rotate(330deg); } 110 | .sk-circle .sk-circle2:before { 111 | -webkit-animation-delay: -1.1s; 112 | animation-delay: -1.1s; } 113 | .sk-circle .sk-circle3:before { 114 | -webkit-animation-delay: -1s; 115 | animation-delay: -1s; } 116 | .sk-circle .sk-circle4:before { 117 | -webkit-animation-delay: -0.9s; 118 | animation-delay: -0.9s; } 119 | .sk-circle .sk-circle5:before { 120 | -webkit-animation-delay: -0.8s; 121 | animation-delay: -0.8s; } 122 | .sk-circle .sk-circle6:before { 123 | -webkit-animation-delay: -0.7s; 124 | animation-delay: -0.7s; } 125 | .sk-circle .sk-circle7:before { 126 | -webkit-animation-delay: -0.6s; 127 | animation-delay: -0.6s; } 128 | .sk-circle .sk-circle8:before { 129 | -webkit-animation-delay: -0.5s; 130 | animation-delay: -0.5s; } 131 | .sk-circle .sk-circle9:before { 132 | -webkit-animation-delay: -0.4s; 133 | animation-delay: -0.4s; } 134 | .sk-circle .sk-circle10:before { 135 | -webkit-animation-delay: -0.3s; 136 | animation-delay: -0.3s; } 137 | .sk-circle .sk-circle11:before { 138 | -webkit-animation-delay: -0.2s; 139 | animation-delay: -0.2s; } 140 | .sk-circle .sk-circle12:before { 141 | -webkit-animation-delay: -0.1s; 142 | animation-delay: -0.1s; } 143 | 144 | @-webkit-keyframes sk-circleBounceDelay { 145 | 0%, 80%, 100% { 146 | -webkit-transform: scale(0); 147 | transform: scale(0); 148 | } 40% { 149 | -webkit-transform: scale(1); 150 | transform: scale(1); 151 | } 152 | } 153 | 154 | @keyframes sk-circleBounceDelay { 155 | 0%, 80%, 100% { 156 | -webkit-transform: scale(0); 157 | transform: scale(0); 158 | } 40% { 159 | -webkit-transform: scale(1); 160 | transform: scale(1); 161 | } 162 | } 163 | 164 | 165 | /* FB Comment */ 166 | 167 | .fb_iframe_widget, 168 | .fb_iframe_widget span, 169 | .fb_iframe_widget iframe[style] {width: 100% !important;} 170 | 171 | -------------------------------------------------------------------------------- /public/js/radar-chart.min.js: -------------------------------------------------------------------------------- 1 | var RadarChart={defaultConfig:{containerClass:"radar-chart",w:600,h:600,factor:.95,factorLegend:1,levels:3,levelTick:!1,TickLength:10,maxValue:0,minValue:0,radians:2*Math.PI,color:d3.scale.category10(),axisLine:!0,axisText:!0,circles:!0,radius:5,backgroundTooltipColor:"#555",backgroundTooltipOpacity:"0.7",tooltipColor:"white",axisJoin:function(a,b){return a.className||b},tooltipFormatValue:function(a){return a},tooltipFormatClass:function(a){return a},transitionDuration:300},chart:function(){function b(b,c){if(0==c||void 0==c)b.classed("visible",0),b.select("rect").classed("visible",0);else{b.classed("visible",1);var d=b.node().parentNode,e=d3.mouse(d);b.select("text").classed("visible",1).style("fill",a.tooltipColor);var f=5,g=b.select("text").text(c).node().getBBox();b.select("rect").classed("visible",1).attr("x",0).attr("x",g.x-f).attr("y",g.y-f).attr("width",g.width+2*f).attr("height",g.height+2*f).attr("rx","5").attr("ry","5").style("fill",a.backgroundTooltipColor).style("opacity",a.backgroundTooltipOpacity),b.attr("transform","translate("+(e[0]+10)+","+(e[1]-10)+")")}}function c(c){c.each(function(c){function l(b,c,d,e){return d="undefined"!=typeof d?d:1,c*(1-d*e(b*a.radians/i))}function m(a,b,c){return l(a,b,c,Math.sin)}function n(a,b,c){return l(a,b,c,Math.cos)}var d=d3.select(this),e=d.selectAll("g.tooltip").data([c[0]]),f=e.enter().append("g").classed("tooltip",!0);f.append("rect").classed("tooltip",!0),f.append("text").classed("tooltip",!0),c=c.map(function(a){return a instanceof Array&&(a={axes:a}),a});var g=Math.max(a.maxValue,d3.max(c,function(a){return d3.max(a.axes,function(a){return a.value})}));g-=a.minValue;var h=c[0].axes.map(function(a,b){return{name:a.axis,xOffset:a.xOffset?a.xOffset:0,yOffset:a.yOffset?a.yOffset:0}}),i=h.length,j=a.factor*Math.min(a.w/2,a.h/2),k=Math.min(a.w/2,a.h/2);d.classed(a.containerClass,1);var o=d3.range(0,a.levels).map(function(b){return j*((b+1)/a.levels)}),p=d.selectAll("g.level-group").data(o);p.enter().append("g"),p.exit().remove(),p.attr("class",function(a,b){return"level-group level-group-"+b});var q=p.selectAll(".level").data(function(a){return d3.range(0,i).map(function(){return a})});if(q.enter().append("line"),q.exit().remove(),a.levelTick?q.attr("class","level").attr("x1",function(b,c){return j==b?m(c,b):m(c,b)+a.TickLength/2*Math.cos(c*a.radians/i)}).attr("y1",function(b,c){return j==b?n(c,b):n(c,b)-a.TickLength/2*Math.sin(c*a.radians/i)}).attr("x2",function(b,c){return j==b?m(c+1,b):m(c,b)-a.TickLength/2*Math.cos(c*a.radians/i)}).attr("y2",function(b,c){return j==b?n(c+1,b):n(c,b)+a.TickLength/2*Math.sin(c*a.radians/i)}).attr("transform",function(b){return"translate("+(a.w/2-b)+", "+(a.h/2-b)+")"}):q.attr("class","level").attr("x1",function(a,b){return m(b,a)}).attr("y1",function(a,b){return n(b,a)}).attr("x2",function(a,b){return m(b+1,a)}).attr("y2",function(a,b){return n(b+1,a)}).attr("transform",function(b){return"translate("+(a.w/2-b)+", "+(a.h/2-b)+")"}),a.axisLine||a.axisText){var r=d.selectAll(".axis").data(h),s=r.enter().append("g");a.axisLine&&s.append("line"),a.axisText&&s.append("text"),r.exit().remove(),r.attr("class","axis"),a.axisLine&&r.select("line").attr("x1",a.w/2).attr("y1",a.h/2).attr("x2",function(b,c){return a.w/2-k+m(c,k,a.factor)}).attr("y2",function(b,c){return a.h/2-k+n(c,k,a.factor)}),a.axisText&&r.select("text").attr("class",function(a,b){var c=m(b,.5);return"legend "+(.4>c?"left":c>.6?"right":"middle")}).attr("dy",function(a,b){var c=n(b,.5);return.1>c?"1em":c>.9?"0":"0.5em"}).text(function(a){return a.name}).attr("x",function(b,c){return b.xOffset+(a.w/2-k)+m(c,k,a.factorLegend)}).attr("y",function(b,c){return b.yOffset+(a.h/2-k)+n(c,k,a.factorLegend)})}c.forEach(function(b){b.axes.forEach(function(b,c){b.x=a.w/2-k+m(c,k,parseFloat(Math.max(b.value-a.minValue,0))/g*a.factor),b.y=a.h/2-k+n(c,k,parseFloat(Math.max(b.value-a.minValue,0))/g*a.factor)})});var t=d.selectAll(".area").data(c,a.axisJoin);if(t.enter().append("polygon").classed({area:1,"d3-enter":1}).on("mouseover",function(c){d3.event.stopPropagation(),d.classed("focus",1),d3.select(this).classed("focused",1),b(e,a.tooltipFormatClass(c.className))}).on("mouseout",function(){d3.event.stopPropagation(),d.classed("focus",0),d3.select(this).classed("focused",0),b(e,!1)}),t.exit().classed("d3-exit",1).transition().duration(a.transitionDuration).remove(),t.each(function(a,b){var c={"d3-exit":0};c["radar-chart-serie"+b]=1,a.className&&(c[a.className]=1),d3.select(this).classed(c)}).style("stroke",function(b,c){return a.color(c)}).style("fill",function(b,c){return a.color(c)}).transition().duration(a.transitionDuration).attr("points",function(a){return a.axes.map(function(a){return[a.x,a.y].join(",")}).join(" ")}).each("start",function(){d3.select(this).classed("d3-enter",0)}),a.circles&&a.radius){var u=d.selectAll("g.circle-group").data(c,a.axisJoin);u.enter().append("g").classed({"circle-group":1,"d3-enter":1}),u.exit().classed("d3-exit",1).transition().duration(a.transitionDuration).remove(),u.each(function(a){var b={"d3-exit":0};a.className&&(b[a.className]=1),d3.select(this).classed(b)}).transition().duration(a.transitionDuration).each("start",function(){d3.select(this).classed("d3-enter",0)});var v=u.selectAll(".circle").data(function(a,b){return a.axes.map(function(a){return[a,b]})});v.enter().append("circle").classed({circle:1,"d3-enter":1}).on("mouseover",function(c){d3.event.stopPropagation(),b(e,a.tooltipFormatValue(c[0].value))}).on("mouseout",function(a){d3.event.stopPropagation(),b(e,!1),d.classed("focus",0)}),v.exit().classed("d3-exit",1).transition().duration(a.transitionDuration).remove(),v.each(function(a){var b={"d3-exit":0};b["radar-chart-serie"+a[1]]=1,d3.select(this).classed(b)}).style("fill",function(b){return a.color(b[1])}).transition().duration(a.transitionDuration).attr("r",a.radius).attr("cx",function(a){return a[0].x}).attr("cy",function(a){return a[0].y}).each("start",function(){d3.select(this).classed("d3-enter",0)});var w=t.node();w.parentNode.appendChild(w);var x=u.node();x.parentNode.appendChild(x);var y=e.node();y.parentNode.appendChild(y)}})}var a=Object.create(RadarChart.defaultConfig);return c.config=function(b){return arguments.length?(arguments.length>1?a[arguments[0]]=arguments[1]:d3.entries(b||{}).forEach(function(b){a[b.key]=b.value}),c):a},c},draw:function(a,b,c){var d=RadarChart.chart().config(c),e=d.config();d3.select(a).select("svg").remove(),d3.select(a).append("svg").attr("width",e.w).attr("height",e.h).datum(b).call(d)}}; -------------------------------------------------------------------------------- /views/components/BudgetGroupTable.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | import cx from 'classnames'; 5 | 6 | import BaseComponent from './BaseComponent.jsx'; 7 | import Loading from './Loading.jsx'; 8 | 9 | export default class BudgetGroupTable extends BaseComponent { 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | sort:null, 15 | sortAsc:false 16 | }; 17 | 18 | } 19 | 20 | doSortBy(field,defAsc){ 21 | if(this.state.sort == field){ 22 | this.setStateWithLoading({sortAsc: !!!this.state.sortAsc }); 23 | }else{ 24 | this.setStateWithLoading({sort:field,sortAsc:defAsc}); 25 | } 26 | } 27 | 28 | doSearch(){ 29 | var input = React.findDOMNode(this.refs.search).value; 30 | this.setStateWithLoading({search:input},500); 31 | } 32 | 33 | 34 | _sumSection(budgets,keys){ 35 | if(budgets == null){ 36 | return []; 37 | } 38 | 39 | var groups = {}; 40 | 41 | budgets.forEach((b) => { 42 | var key_array = []; 43 | keys.forEach((k) => { 44 | key_array.push(b[k]); 45 | }); 46 | var key = key_array.join(""); 47 | 48 | groups[key] = groups[key] || {name:"",amount:0,last_amount:0,change:0}; 49 | 50 | if(groups[key][keys[0]] == null){ //first init 51 | keys.forEach((k) => { 52 | groups[key][k] = b[k]; 53 | }); 54 | } 55 | 56 | if(b.last_amount){ 57 | groups[key].last_amount += parseInt(b.last_amount,10); 58 | } 59 | 60 | groups[key].amount += parseInt(b.amount,10); 61 | groups[key].change += parseInt(b.change,10); 62 | 63 | }); 64 | 65 | var sections = []; 66 | for(var k in groups){ 67 | sections.push(groups[k]); 68 | } 69 | return sections; 70 | } 71 | 72 | componentWillReceiveProps(nextProps) { 73 | 74 | if(nextProps.keys == null || this.props.keys == null){ 75 | return true; 76 | } 77 | 78 | if(Object.keys(nextProps.keys).length != Object.keys(this.props.keys)){ 79 | this.setState({ 80 | sort:null, 81 | sortAsc:false 82 | }); 83 | } 84 | 85 | } 86 | 87 | 88 | render(){ 89 | if(this.props.keys == null){ 90 | return null; 91 | } 92 | var {search,sort,sortAsc} = this.state; 93 | var items = this._sumSection(this.props.items,Object.keys(this.props.keys)); 94 | 95 | // var hide = {}; 96 | 97 | if(search != null && $.trim(search) != ""){ 98 | let newitems = []; 99 | items.forEach((item) => { 100 | for(var k in this.props.keys){ 101 | if(item[k].indexOf(search) != -1){ 102 | newitems.push(item); 103 | 104 | break; 105 | } 106 | } 107 | }); 108 | items = newitems; 109 | } 110 | 111 | if(sort != null){ 112 | items = items.sort( (a,b) => { 113 | return (parseInt(a[sort],10) - parseInt(b[sort],10)) * (sortAsc ? 1 :-1 ); 114 | }); 115 | 116 | } 117 | // var {drilldown} = data; 118 | var s = new Date().getTime(); 119 | var h = ( 120 |
    121 |

    122 | 123 |
    124 | (搜尋{Object.keys(this.props.keys).map(key => ( 125 | "["+this.props.keys[key].name+"]" 126 | ) )}名稱、模糊比對) 127 |

    128 | 129 | 130 | 131 | {Object.keys(this.props.keys).map(key => ( 132 | 133 | ) )} 134 | 135 | 145 | 146 | 157 | 158 | {(this.props.waiting || this.state._waiting ) && 159 | 160 | } 161 | {items.map( b => 162 | 163 | { 164 | Object.keys(this.props.keys).map(key => ( 165 | 166 | )) 167 | } 168 | 169 | 170 | 180 | 181 | )} 182 | 183 | 184 |
    {this.props.keys[key].name}金額   136 | 144 | 總預算約 147 | 前一年差額   148 | 156 |
    {b[key]}{b.amount} {unitconverter.convert(b.amount,null,false)} 171 | {( 172 | b.last_amount != null? 173 | (
    0 ? "green" :"red")}}> 174 | {unitconverter.percent(b.change,b.last_amount)} 175 |
    176 | (約差 {unitconverter.convert(b.change, null, true) }) 177 |
    ): 178 | "無之前資料" 179 | )}
    185 |
    186 | ); 187 | // console.log("used:"+(new Date().getTime()-s)); 188 | return h; 189 | } 190 | } 191 | 192 | -------------------------------------------------------------------------------- /budget_list.js.sample: -------------------------------------------------------------------------------- 1 | 2 | export default [ 3 | { 4 | "id": 1, 5 | "name": "台北市", 6 | "title": "台北市 2016 總預算", 7 | "ogimage": "http://tpebudget.tonyq.org/img/ogimage.png", 8 | "description": "快來瞭解台北市 2016 年預算類型、內容!", 9 | "budgets": [ 10 | "https://cdn.rawgit.com/tony1223/6a3bee53b175b2d4429f/raw/5e6cffa9d2d6bed87401156c66d3424952a7bf9e/gistfile1.txt", 11 | "https://cdn.rawgit.com/tony1223/2cf039240afb660a9886/raw/f4f67bf3591b36ff4e35558b596f5a2168033446/gistfile1.txt" 12 | ], 13 | "ts": "2015-10-04T06:21:50.164Z", 14 | "ts_update": "2015-10-04T06:21:50.164Z", 15 | "user_id": "1", 16 | "rawfile": null, 17 | "city": "台北市", 18 | "budget_file_type": "0", 19 | "tags": null, 20 | "reference": null 21 | }, 22 | { 23 | "id": 2, 24 | "name": "高雄市", 25 | "title": "高雄市 2016 總預算", 26 | "ogimage": "http://ksbudget.tonyq.org/img/ogimage.png", 27 | "description": "快來瞭解高雄市 2016 年預算類型、內容!", 28 | "budgets": [ 29 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/ks_budget_2016.json" 30 | ], 31 | "ts": "2015-10-04T06:51:29.977Z", 32 | "ts_update": "2015-10-04T06:51:29.977Z", 33 | "user_id": "1", 34 | "rawfile": null, 35 | "city": "高雄市", 36 | "budget_file_type": "0", 37 | "tags": null, 38 | "reference": null 39 | }, 40 | { 41 | "id": 3, 42 | "name": "嘉義縣", 43 | "title": "嘉義縣 2016 總預算", 44 | "ogimage": null, 45 | "description": "快來瞭解嘉義縣 2016 年預算類型、內容!", 46 | "budgets": [ 47 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/chaiyi_2016_budget.json" 48 | ], 49 | "ts": "2015-10-04T14:54:42.804Z", 50 | "ts_update": "2015-10-04T14:54:42.804Z", 51 | "user_id": "1", 52 | "rawfile": null, 53 | "city": "嘉義市", 54 | "budget_file_type": "0", 55 | "tags": null, 56 | "reference": null 57 | }, 58 | { 59 | "id": 4, 60 | "name": "台中市", 61 | "title": "台中市 2016 總預算", 62 | "ogimage": null, 63 | "description": "快來瞭解台中市 2016 年預算類型、內容!", 64 | "budgets": [ 65 | "https://cdn.rawgit.com/tony1223/tpe-2016-budget/d40746029c58cbc39fd24a06282d69e86cfdf419/output/%E6%AD%B2%E5%87%BA%E6%A9%9F%E9%97%9C%E5%88%A5%E9%A0%90%E7%AE%97%E8%A1%A8_g0v.json" 66 | ], 67 | "ts": "2015-10-07T14:23:21.480Z", 68 | "ts_update": "2015-10-07T14:23:21.480Z", 69 | "user_id": "1", 70 | "rawfile": null, 71 | "city": "台中市", 72 | "budget_file_type": "0", 73 | "tags": null, 74 | "reference": null 75 | }, 76 | { 77 | "id": 5, 78 | "name": "台中市 - 研考會", 79 | "title": "台中市研考會 2016 總預算", 80 | "ogimage": null, 81 | "description": "快來瞭解台中市研考會 2016 年預算類型、內容!", 82 | "budgets": [ 83 | "https://cdn.rawgit.com/tony1223/tpe-2016-budget/7866f031f819846d5d41a59c2e282c51eca26401/output/歲出機關別預算表_研考會_g0v.json" 84 | ], 85 | "ts": "2015-10-07T14:23:21.480Z", 86 | "ts_update": "2015-10-07T14:23:21.480Z", 87 | "user_id": "1", 88 | "rawfile": null, 89 | "city": "台中市", 90 | "budget_file_type": "0", 91 | "tags": null, 92 | "reference": null 93 | }, 94 | { 95 | "id": 6, 96 | "name": "宜蘭縣", 97 | "title": "宜蘭縣 2016 總預算", 98 | "ogimage": null, 99 | "description": "快來瞭解宜蘭縣2016 年預算類型、內容!", 100 | "budgets": [ 101 | "https://cdn.rawgit.com/tony1223/tpe-2016-budget/7d0b9fad1dea6fc072faf6b1488290fea9a0b880/output/%E6%AD%B2%E5%87%BA%E6%A9%9F%E9%97%9C%E5%88%A5%E9%A0%90%E7%AE%97%E8%A1%A8_g0v.json" 102 | ], 103 | "ts": "2015-12-28T06:46:16.170Z", 104 | "ts_update": "2015-12-28T06:46:16.170Z", 105 | "user_id": "1", 106 | "rawfile": null, 107 | "city": "宜蘭縣", 108 | "budget_file_type": "0", 109 | "tags": null, 110 | "reference": null 111 | }, 112 | { 113 | "id": 7, 114 | "name": "台北市", 115 | "title": "台北市 2017 總預算", 116 | "ogimage": null, 117 | "description": "快來瞭解台北市 2017 年預算類型、內容!", 118 | "budgets": [ 119 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/tpe_2017.csv" 120 | ], 121 | "ts": "2016-10-15T21:04:14.078Z", 122 | "ts_update": "2016-10-15T21:04:14.078Z", 123 | "user_id": "1", 124 | "rawfile": null, 125 | "city": "台北市", 126 | "budget_file_type": "1", 127 | "tags": null, 128 | "reference": null 129 | }, 130 | { 131 | "id": 8, 132 | "name": "嘉義縣", 133 | "title": "嘉義縣 2017 總預算", 134 | "ogimage": null, 135 | "description": "快來瞭解嘉義縣 2017 年預算類型、內容!", 136 | "budgets": [ 137 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/chaiyi_2017_budget.json" 138 | ], 139 | "ts": "2016-10-16T02:29:01.030Z", 140 | "ts_update": "2016-10-16T02:29:01.030Z", 141 | "user_id": "1", 142 | "rawfile": null, 143 | "city": "嘉義市", 144 | "budget_file_type": "0", 145 | "tags": null, 146 | "reference": null 147 | }, 148 | { 149 | "id": 9, 150 | "name": "高雄市", 151 | "title": "高雄市 2017 總預算", 152 | "ogimage": null, 153 | "description": "快來瞭解高雄市 2017 年預算類型、內容!", 154 | "budgets": [ 155 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/ks_budget_2017.json" 156 | ], 157 | "ts": "2016-10-16T03:11:06.403Z", 158 | "ts_update": "2016-10-16T03:11:06.403Z", 159 | "user_id": "1", 160 | "rawfile": null, 161 | "city": "高雄市", 162 | "budget_file_type": "0", 163 | "tags": null, 164 | "reference": null 165 | }, 166 | { 167 | "id": 10, 168 | "name": "中央政府", 169 | "title": "中央政府 2017 總預算", 170 | "ogimage": null, 171 | "description": "快來瞭解中央政府 2017 年預算類型、內容!", 172 | "budgets": [ 173 | "http://budget.tonyqstatic.org.s3.amazonaws.com/files/gov_budget_2017.json" 174 | ], 175 | "ts": "2016-10-16T06:31:28.953Z", 176 | "ts_update": "2016-10-16T06:31:28.953Z", 177 | "user_id": "1", 178 | "rawfile": null, 179 | "city": "中央政府", 180 | "budget_file_type": "0", 181 | "tags": null, 182 | "reference": null 183 | }, 184 | { 185 | "id": 11, 186 | "name": "經濟部單位預算", 187 | "title": "經濟部 2017 單位預算", 188 | "ogimage": null, 189 | "description": null, 190 | "budgets": [ 191 | "http://budget.tonyqstatic.org.s3.amazonaws.com/cli/budget_2601.json" 192 | ], 193 | "ts": "2016-10-16T22:00:49.272Z", 194 | "ts_update": "2016-10-16T22:00:49.272Z", 195 | "user_id": null, 196 | "rawfile": null, 197 | "city": "中央政府", 198 | "budget_file_type": "0", 199 | "tags": null, 200 | "reference": null 201 | } 202 | ]; -------------------------------------------------------------------------------- /views/helpers/util.js: -------------------------------------------------------------------------------- 1 | import Promise from "bluebird"; 2 | import d3 from "d3"; 3 | import CommentHelper from "./comment.jsx"; 4 | 5 | 6 | var util = { 7 | base_url:function(str){ 8 | if(str && str[0] == "/"){ 9 | return str; 10 | } 11 | return "/"+str; 12 | }, 13 | site_url:function(str){ 14 | if(str && str[0] == "/"){ 15 | return str; 16 | } 17 | return "/"+str; 18 | }, 19 | asset_url:function(str){ 20 | if(str && str[0] == "/"){ 21 | return str; 22 | } 23 | return "/"+str; 24 | }, 25 | format_date:function(d){ 26 | var pad = function(num){ 27 | if(num < 10){ 28 | return "0"+num; 29 | } 30 | return num; 31 | }; 32 | if(!isNaN(d)){ 33 | d= new Date(d); 34 | } 35 | if(d.getTime){ //date 36 | return d.getFullYear()+"/"+ pad(d.getMonth()+1) 37 | +"/" + pad(d.getDate()) 38 | +" "+pad(d.getHours()) 39 | +":"+pad(d.getMinutes()) 40 | +":"+pad(d.getSeconds()); 41 | } 42 | }, 43 | watch:function(label){ 44 | return function(d){ 45 | // console.log(label,d); 46 | return Promise.resolve(d); 47 | }; 48 | }, 49 | //analyticProvider.get_last_comment(null,1,200).then(function(comments){ 50 | timeout:function(promise,time,timeout){ 51 | var p = new Promise(function(ok,fail){ 52 | var flag = false ,resolved = false; 53 | promise.then(function(datas){ 54 | if(!resolved) { 55 | ok(datas); 56 | } 57 | flag = true; 58 | },fail); 59 | setTimeout(function(){ 60 | if(!flag){ 61 | resolved = true; 62 | timeout(ok); 63 | } 64 | },time); 65 | }); 66 | return p; 67 | }, 68 | requestJSONs(urls){ 69 | 70 | if(global.window){ 71 | if(urls == null || ! urls.length ){ 72 | return Promise.resolve([]); 73 | } 74 | var promises = urls.map((url)=>{ 75 | var p = new Promise((ok,fail)=>{ 76 | $.get(url).then(function(body){ 77 | var _body = body; 78 | if(body != null && typeof body =="string"){ 79 | _body = JSON.parse(body); 80 | } 81 | 82 | ok(_body); 83 | 84 | }); 85 | }); 86 | return p; 87 | }); 88 | 89 | return Promise.all(promises); 90 | } 91 | 92 | throw "client only"; 93 | }, 94 | request_csv(url,transform){ 95 | if(global.window){ 96 | return new Promise((ok,fail)=>{ 97 | d3.csv(url, transform || ((d)=>d), function(error, rows) { 98 | if(error){ 99 | fail("fail to parse:"+url); 100 | } 101 | else{ 102 | ok(rows); 103 | } 104 | }); 105 | }); 106 | 107 | } 108 | throw "client only"; 109 | 110 | }, 111 | each:function(ary,cb){ 112 | if(ary == null){ 113 | return true; 114 | } 115 | if(ary.forEach){ 116 | ary.forEach(cb); 117 | }else{ 118 | for(var i = 0; i < ary.length;++i){ 119 | if(cb){ 120 | var r = cb(ary[i],i); 121 | if(r === false){ 122 | break; 123 | } 124 | } 125 | } 126 | } 127 | }, 128 | combine:function(arrs){ 129 | var out = []; 130 | this.each(arrs,function(){ 131 | out.push.apply(out,ary); 132 | }); 133 | return ary; 134 | }, 135 | err:function(){ 136 | return function(){ 137 | // console.log("fail:",message); 138 | // console.log(arguments); 139 | }; 140 | }, 141 | getBudgetInfos_v0_default_json(budget_file_type,budget_links){ 142 | 143 | return this.requestJSONs(budget_links).then((datas)=>{ 144 | var res = datas[0]; 145 | if(res.length && datas.length > 1 && res[0].last_amount == null){ 146 | var map = {}; 147 | datas[1].forEach((data)=>{ map[data.code] = data.amount; }); 148 | 149 | res.forEach((r) => { 150 | r.last_amount = parseInt(map[r.code] || 0,10); 151 | r.change = parseInt(r.amount,10) - parseInt(r.last_amount,10) ; 152 | r.comment = CommentHelper.refine(r.comment); 153 | }); 154 | }else{ 155 | res.forEach((r) => { 156 | r.change = parseInt(r.amount,10) - parseInt(r.last_amount,10) ; 157 | r.comment = CommentHelper.refine(r.comment); 158 | }); 159 | } 160 | return res; 161 | }); 162 | }, 163 | parseFormalNumbers(num){ 164 | return parseInt(num.replace(/,/g,""),10); 165 | }, 166 | refine_amount(num){ 167 | return CommentHelper._refine_amount(num); 168 | }, 169 | getBudgetInfos_v1_header_csv(budget_file_type,budget_links){ 170 | 171 | var map = {}; 172 | 173 | var codeFn = function(items){ 174 | var out =[]; 175 | items.forEach(function(step){ 176 | if(!(step == "0" || step == ""|| step == null)){ 177 | out.push(step); 178 | } 179 | }); 180 | 181 | return out.join("-"); 182 | 183 | }; 184 | return this.request_csv(budget_links[0],(data)=>{ 185 | var steps = [data["款"] ,data["項"] ,data["目"],data["節"]]; 186 | var code = codeFn(steps); 187 | map[code] = data["名稱"]; 188 | 189 | if(data["節"] == "0" || data["節"] == ""|| data["節"] == null){ 190 | return null; 191 | } 192 | var result = { 193 | // "year": 2016, 194 | "code": code, 195 | "last_amount":this.parseFormalNumbers(data["上年度預算數"]) , 196 | "amount": this.parseFormalNumbers(data["本年度預算數"]), 197 | "name": data["名稱"], 198 | "topname": map[codeFn(steps.slice(0,1))], 199 | "depname": map[codeFn(steps.slice(0,2))], 200 | "depcat": map[codeFn(steps.slice(0,3))], 201 | "category": map[codeFn(steps.slice(0,3))], 202 | "cat": map[codeFn(steps.slice(0,1))], 203 | "ref": code, 204 | "comment": CommentHelper.refine(data["說明"]), 205 | "change": this.parseFormalNumbers(data["本年度預算數"]) - this.parseFormalNumbers(data["上年度預算數"]) 206 | }; 207 | return result; 208 | }).then((items) => items.filter(n=>n!=null)); 209 | }, 210 | process_meta_link:function(meta_links){ 211 | if(meta_links == null){ 212 | return Promise.resolve({}); 213 | } 214 | 215 | var results = {}; 216 | var promises = Object.keys(meta_links).map((key) =>{ 217 | return this.requestJSONs([meta_links[key]]).then(([data])=>{ 218 | if(key == "purpose"){ //用途別 219 | data.Root.Row.forEach(function(item){ 220 | results[item["預算科目編號"]] = results[item["預算科目編號"]]||{}; 221 | results[item["預算科目編號"]]["purpose"] = item; 222 | }); 223 | } 224 | }); 225 | }); 226 | return Promise.all(promises).then(function(){ 227 | return results; 228 | }); 229 | }, 230 | process_gov_type(meta_links){ 231 | if(meta_links && meta_links.gov_type){ 232 | return this.requestJSONs([meta_links["gov_type"]]) 233 | .then(([data,summary])=>{ 234 | 235 | return data.map(d=>{ 236 | return {"代碼": d["編號"],"名稱":d["名稱"],"大政式分類":d["大政式分類"]}; 237 | }); 238 | 239 | }); 240 | } 241 | return Promise.resolve(null); 242 | }, 243 | getBudgetInfos(budget_file_type,budget_links){ 244 | 245 | if(budget_file_type =="1"){ 246 | return this.getBudgetInfos_v1_header_csv(budget_file_type,budget_links); 247 | } 248 | if(budget_file_type == null || budget_file_type == 0 || budget_file_type == 2){ 249 | return this.getBudgetInfos_v0_default_json(budget_file_type,budget_links); 250 | } 251 | 252 | } 253 | 254 | }; 255 | 256 | module.exports = util; -------------------------------------------------------------------------------- /views/controller/bubble.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | // import rd3 from 'react-d3'; 5 | 6 | import D3BudgetBubble from './../components/d3BudgetBubble.jsx'; 7 | 8 | import CommentHelper from './../helpers/comment.jsx'; 9 | import Loading from './../components/Loading.jsx'; 10 | import FBComment from './../components/fb/FBComment.jsx'; 11 | var ReactDisqusThread = require('react-disqus-thread'); 12 | 13 | import cx from 'classnames'; 14 | import Util from '../helpers/util'; 15 | import Promise from "bluebird"; 16 | 17 | import BaseComponent from './../components/BaseComponent.jsx'; 18 | export default class Bubble extends BaseComponent { 19 | 20 | constructor(props) { 21 | super(props); 22 | this.state = { 23 | infoBudget:null, 24 | selectedBudget:null, 25 | groupKey:"topname", 26 | info_state:1 27 | }; 28 | 29 | if(global.window != null){ 30 | Promise.all([ 31 | Util.getBudgetInfos(this.props.budget_file_type,this.props.budget_links), 32 | Util.process_gov_type(this.props.budget_meta_links) 33 | ]).then(([res,gov_types])=>{ 34 | var last_res = res; 35 | if(gov_types){ 36 | var typeMap = {}; 37 | gov_types.forEach(function(type){ 38 | typeMap[type["代碼"]] = type["名稱"]; 39 | }); 40 | last_res = res.map((r)=>{ 41 | var gov_key = $.trim(r.code).substring(0,2); 42 | r.gov_type = typeMap[gov_key]; 43 | return r; 44 | }); 45 | } 46 | 47 | this.setState({ 48 | last_budget:last_res, 49 | waiting:false 50 | }); 51 | }); 52 | 53 | } 54 | } 55 | 56 | 57 | componentDidMount() { 58 | 59 | } 60 | 61 | componentDidUpdate() { 62 | 63 | } 64 | 65 | 66 | componentWillUnmount() { 67 | } 68 | 69 | onBudgetClick(d,cluster){ 70 | this.setState({selectedBudget:d}); 71 | $(document.body).addClass("modal-open"); 72 | // console.log(d,cluster); 73 | } 74 | 75 | onBudgetOver(d,cluster){ 76 | this.setState({infoBudget:d}); 77 | // console.log(d,cluster); 78 | } 79 | 80 | onChangeGroupKey(type){ 81 | this.setState({groupKey:type}); 82 | } 83 | 84 | 85 | _name(d) { 86 | return `${d.topname} > ${d.depname} > ${d.category} > ${d.name} `; 87 | } 88 | 89 | cancelSelect(){ 90 | this.setState({selectedBudget:null}); 91 | $(document.body).removeClass("modal-open"); 92 | } 93 | 94 | 95 | toogle_info_state(state){ 96 | this.setState({info_state:this.state.info_state == 1 ? 0 : 1}); 97 | } 98 | 99 | render(){ 100 | // var {data,selectedDrill} = this.state; 101 | // var {drilldown} = data; 102 | var {selectedBudget,infoBudget} = this.state; 103 | 104 | var budgetComments = null; 105 | if(selectedBudget){ 106 | if(this.props.budget_id == 1){ 107 | budgetComments = ; 108 | }else if(this.props.budget_id <= 6){ 109 | budgetComments = 110 | }else{ 111 | budgetComments = ; 118 | } 119 | } 120 | 121 | return ( 122 |
    123 |
    124 | {this.state.last_budget && 125 |
    126 |
    127 | 128 | {this.state.last_budget && this.state.last_budget[0].gov_type && 129 | ()} 130 |
    131 | 133 |
    134 | } 135 | {this.state.last_budget == null && } 136 |
    137 | { 138 | infoBudget && 139 |
    154 | {selectedBudget == null && 155 |

    159 | } 160 | 161 | 162 |
    163 | {selectedBudget &&
     
    166 | } 167 |

    {this._name(infoBudget) }

    168 | {(this.state.info_state ==1 ||selectedBudget) && (
    169 | 170 |

    科目代碼:{infoBudget.code}

    171 |

    本年度預算:{unitconverter.convert(infoBudget.amount,null,false)}

    172 |

    前一年度預算:{unitconverter.convert(infoBudget.last_amount,null,false)} 173 | {infoBudget.change != null && infoBudget.change != 0 && 174 | {unitconverter.percent(infoBudget.change,infoBudget.last_amount)} 175 | } 176 |

    177 |
    178 |
    )} 179 | 180 |
    181 | 182 | {selectedBudget &&
    183 | 184 | 詳細資料 185 |
    186 |
    187 |
    188 |
    189 |
    190 |
    191 |
    192 | 網友留言 193 |
    194 |
    195 | { 196 | budgetComments 197 | } 198 |
    199 |
    } 200 | 201 |
    202 | } 203 |
    204 | ); 205 | } 206 | } 207 | 208 | 209 | if(global.window != null){ 210 | React.render(React.createElement(Bubble,window.react_data), document.getElementById("react-root")); 211 | } 212 | 213 | -------------------------------------------------------------------------------- /views/components/BudgetTable.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | import cx from 'classnames'; 5 | 6 | import BaseComponent from './BaseComponent.jsx'; 7 | import Loading from './Loading.jsx'; 8 | import Util from '../helpers/util'; 9 | 10 | 11 | class BudgetTableRow extends BaseComponent { 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state ={open:false}; 16 | } 17 | 18 | 19 | onMoreDetail(b){ 20 | 21 | // if(this.state.explored[b.code]){ 22 | // $(React.findDOMNode(this.refs['detail-'+b.code])).show(); 23 | // }else{ 24 | // $(React.findDOMNode(this.refs['detail-'+b.code])).hide(); 25 | // } 26 | this.setState({open:!!!this.state.open}); 27 | 28 | } 29 | 30 | // shouldComponentUpdate(){ 31 | // return false; 32 | // } 33 | _code(code){ 34 | if(code.indexOf("-") == -1){ 35 | return code; 36 | } 37 | return code.split("-")[1]; 38 | } 39 | 40 | 41 | _find_meta_details(){ 42 | if(this.props.codeMetas == null){ 43 | return null; 44 | } 45 | 46 | var codeMetas = this.props.codeMetas; 47 | 48 | var metas = codeMetas[this.props.item.code] && codeMetas[this.props.item.code]["purpose"]; 49 | if(!metas){ 50 | return null; 51 | } 52 | var ignorefield=["款","項","目","節","科目名稱","預算科目編號"]; 53 | ignorefield.forEach((key)=>{ 54 | if(metas[key]){ 55 | delete metas[key]; 56 | } 57 | }); 58 | 59 | var sortnum = function(name){ 60 | if(name.indexOf("_資") != -1){ 61 | return 2; 62 | } 63 | if(name.indexOf("_經") != -1){ 64 | return 3; 65 | } 66 | if(name.indexOf("小計") != -1){ 67 | return 4; 68 | } 69 | if(name.indexOf("合計") != -1){ 70 | return 5; 71 | } 72 | return 1; 73 | }; 74 | var okeys = Object.keys(metas).sort(function(n1,n2){ 75 | return sortnum(n1) - sortnum(n2); 76 | }); 77 | 78 | return okeys.map((key)=>{ 79 | return {name:key,value:metas[key] * 1000}; 80 | }).filter(k=>k.value > 0); 81 | } 82 | 83 | render(){ 84 | var b = this.props.item; 85 | var meta_purpose_info = null; 86 | 87 | if(this.state.open){ 88 | meta_purpose_info = this._find_meta_details(); 89 | } 90 | return ( 91 | 92 | 93 | {b.year} 94 | {this._code(b.code)} 95 | {b.topname} 96 | {b.depname} 97 | {b.category} 98 | {b.name} 99 | {unitconverter.convert(parseInt(b.amount,10), null, true)} 100 | 101 |
    0 ? "green" :"red")}}> 102 | {unitconverter.percent(b.change,b.last_amount)} 103 |
    104 | (約差 {unitconverter.convert(b.change, null, true) }) 105 |
    106 | 107 | 108 | 109 | {this.props.showDetail && 110 | 111 | 112 | 115 | 116 | 117 | } 118 | 119 | { meta_purpose_info && ( 120 | 121 | 122 | 123 | {meta_purpose_info.map((data)=>
    124 |

    {data.name}: 125 | 126 | 127 | 128 |

    129 |
    )} 130 | 131 | 132 | )} 133 | 134 | 135 | ); 136 | } 137 | } 138 | 139 | 140 | export default class BudgetTable extends BaseComponent { 141 | 142 | constructor(props) { 143 | super(props); 144 | this.state = { 145 | sort:null, 146 | sortAsc:false 147 | }; 148 | 149 | } 150 | 151 | doSortBy(field,defAsc){ 152 | if(this.state.sort == field){ 153 | this.setStateWithLoading({sortAsc: !!!this.state.sortAsc }); 154 | }else{ 155 | this.setStateWithLoading({sort:field,sortAsc:defAsc}); 156 | } 157 | } 158 | 159 | doSearch(){ 160 | var input = React.findDOMNode(this.refs.search).value; 161 | this.setStateWithLoading({search:input},500); 162 | } 163 | 164 | render(){ 165 | var {search,sort,sortAsc} = this.state; 166 | var _items = this.props.items || []; 167 | var items = _items; 168 | var steps = ["topname","depname","category","name"]; 169 | 170 | var filter = this.props.filter; 171 | if(filter){ 172 | items = []; 173 | _items.forEach((item)=>{ 174 | var matchs = steps.filter((key,ind) =>{ 175 | if(ind >= filter.length){ return true; } 176 | if(item[key] == filter[ind]){ 177 | return true; 178 | }else{ 179 | return false; 180 | } 181 | }); 182 | if(matchs.length == steps.length ){ 183 | items.push(item); 184 | } 185 | }); 186 | } 187 | var hide = {}; 188 | 189 | if(search != null && $.trim(search) != ""){ 190 | let newitems = []; 191 | items.forEach((item)=>{ 192 | var matchs = steps.filter((key) => item[key].indexOf(search) != -1); 193 | if(matchs.length > 0 ){ 194 | newitems.push(item); 195 | }else{ 196 | hide[item.code] = 1; 197 | } 198 | }); 199 | items = newitems; 200 | } 201 | if(sort != null){ 202 | items = items.sort( (a,b) => { return (a[sort] - b[sort]) * (sortAsc ? 1 :-1 ); } ) 203 | } 204 | // var {drilldown} = data; 205 | var s = new Date().getTime(); 206 | var h = ( 207 |
    208 |

    209 | 210 |
    211 | (搜尋款項目名稱、模糊比對) 212 |

    213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 232 | 243 | 246 | 247 | 248 | {(this.props.waiting || this.state._waiting ) && 249 | 250 | } 251 | { !(this.props.waiting || this.state._waiting ) && items && items.map((b)=>{ 252 | return 253 | })} 254 |
    年份代碼名稱金額   223 | 231 | 233 | 前一年差額   234 | 242 | 細節 244 | 245 |
    255 |
    256 | ); 257 | // console.log("used:"+(new Date().getTime()-s)); 258 | return h; 259 | } 260 | } 261 | 262 | -------------------------------------------------------------------------------- /views/components/d3BudgetTreemap.jsx: -------------------------------------------------------------------------------- 1 | 2 | import d3 from 'd3'; 3 | 4 | //Reference 5 | //http://bost.ocks.org/mike/treemap/ 6 | import unitconverter from "./../helpers/unitconverter.jsx"; 7 | 8 | export default class D3BudgetTreeMap{ 9 | 10 | constructor(el,props,state){ 11 | 12 | var defaultProps = {width:960,height:500}; 13 | 14 | for(var k in defaultProps){ 15 | if(props[k] == null){ 16 | props[k] = defaultProps[k]; 17 | } 18 | } 19 | 20 | this.props = props; 21 | 22 | this.current_level = 0; 23 | 24 | var margin = {top: 40, right: 0, bottom: 0, left: 0}, 25 | width = props.width, 26 | height = props.height - margin.top - margin.bottom; 27 | 28 | this.transitioning = null; 29 | 30 | var svg = this.svg = d3.select(el).append("svg") 31 | .attr("width", width + margin.left + margin.right) 32 | .attr("height", height + margin.bottom + margin.top) 33 | .style("margin-left", -margin.left + "px") 34 | .style("margin.right", -margin.right + "px") 35 | .append("g") 36 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")") 37 | .style("shape-rendering", "crispEdges"); 38 | 39 | var dataScale = d3.scale.linear() 40 | .domain([1000000, 41 | 100000000]); 42 | dataScale.range([0,100]); 43 | 44 | var treemap = this.treemap = d3.layout.treemap() 45 | .children(function(d, depth) { return depth ? null : d._children; }) 46 | .sort(function(a, b) { return a.value - b.value; }) 47 | .ratio( (height / width) * 0.5 * (0.5 + Math.sqrt(5))) 48 | // .mode("dice") 49 | .round(false); 50 | 51 | var grandparent = this.grandparent = svg.append("g") 52 | .attr("class", "grandparent"); 53 | 54 | grandparent.append("rect") 55 | .attr("y", -margin.top) 56 | .attr("width", width) 57 | .attr("height", margin.top); 58 | 59 | grandparent.append("text") 60 | .attr("x", 6) 61 | .attr("y", 6 - margin.top) 62 | .attr("dy", ".75em"); 63 | 64 | grandparent.on("mouseover", ()=>{ 65 | this.onOver(this.currentDrill || this.root); 66 | }); 67 | 68 | this.update(el, state); 69 | } 70 | 71 | update(el,state){ 72 | var props = this.props; 73 | var margin = {top: 40, right: 0, bottom: 0, left: 0}, 74 | width = props.width, 75 | height = props.height - margin.top - margin.bottom; 76 | 77 | var grandparent = this.grandparent ; 78 | 79 | var svg = this.svg; 80 | 81 | var root = state.root; 82 | this.root = root; 83 | 84 | this.root = root; 85 | var x = d3.scale.linear() 86 | .domain([0, width]) 87 | .range([0, width]); 88 | 89 | var y = d3.scale.linear() 90 | .domain([0, height]) 91 | .range([0, height]); 92 | 93 | this.scales = {x,y}; 94 | 95 | root = this._initialize(root); 96 | this._accumulate(root); 97 | this._layout(this.treemap,root); 98 | this._display(this.grandparent,root); 99 | 100 | } 101 | 102 | 103 | _accumulate(d) { 104 | // if children exist , sum all children value 105 | if(d._children = d.children){ 106 | return d.value = d.children.reduce((p, v) => { 107 | var val = this._accumulate(v); 108 | if(isNaN(val)){ 109 | return p; 110 | } 111 | return p + val 112 | }, 0); 113 | }else{ 114 | if(isNaN(d.value)){ 115 | return 0; 116 | } 117 | return d.value; 118 | } 119 | } 120 | 121 | onOver(d){ 122 | this.props.onOverBudget && this.props.onOverBudget(d); 123 | return true; 124 | } 125 | 126 | onSelect(d){ 127 | this.currentDrill = d; 128 | this.props.onSelect && this.props.onSelect(d); 129 | return true; 130 | } 131 | 132 | _display(grandparent,d) { 133 | this.curret_drill = d; 134 | var formatNumber = d3.format(",d"); 135 | 136 | var g1 = this.svg.insert("g", ".grandparent") 137 | .datum(d) 138 | .attr("class", "depth"); 139 | 140 | grandparent 141 | .datum(d.parent) 142 | .on("click", (d) => { 143 | this.onSelect(d); 144 | this._transition(g1,d); 145 | }) 146 | .select("text") 147 | .text(this._name(d)); 148 | 149 | var g = g1.selectAll("g") 150 | .data(d._children) 151 | .enter().append("g"); 152 | 153 | g.filter(function(d) { return d._children; }) 154 | .classed("children", true) 155 | .on("click", (d) => { 156 | this.onSelect(d); 157 | this._transition(g1,d); 158 | this.onOver(d); 159 | }) 160 | .on("mouseover", this.onOver.bind(this)) 161 | .on("mouseout",() => { 162 | 163 | this.onOver.apply(this, 164 | [ 165 | this.currentDrill || this.root 166 | ]); 167 | }); 168 | 169 | g.selectAll(".child") 170 | .data(function(d) { return d._children || [d]; }) 171 | .enter() 172 | .append("rect") 173 | .attr("class", "child") 174 | .call(this._rect.bind(this)); 175 | 176 | g.append("rect") 177 | .attr("class", "parent") 178 | .call(this._rect.bind(this)) 179 | .append("title") 180 | .text(function(d) { return formatNumber(d.value); }); 181 | 182 | 183 | g.append("text") 184 | .attr("dy", ".75em") 185 | .text((d) =>{ 186 | var {x,y} = this.scales; 187 | // if((x(d.x + d.dx) - x(d.x) ) < 100 ){ 188 | // return ""; 189 | // } 190 | return d.name.replace(/[ \t\n\r]+/,""); 191 | }) 192 | .call(this._text.bind(this)); 193 | 194 | g.append("text") 195 | .attr("dy", "1.95em") 196 | .text((d) => { 197 | var {x,y} = this.scales; 198 | // if((x(d.x + d.dx) - x(d.x) ) < 100 ){ 199 | // return ""; 200 | // } 201 | return unitconverter.convert(d.value,null) ; 202 | }) 203 | .call(this._text.bind(this)); 204 | 205 | g.append("text") 206 | .attr("dy",(d)=>{ 207 | return "3.15em"; 208 | } ) 209 | .text((d) => { 210 | var {x,y} = this.scales; 211 | // if((x(d.x + d.dx) - x(d.x) ) < 50 ){ 212 | // return ""; 213 | // } 214 | return ""+ (parseInt((d.value / this.root.value )* 10000,10)/100) +"%";; 215 | }) 216 | .call(this._text.bind(this)); 217 | 218 | return g; 219 | } 220 | 221 | _transition(g1,d) { 222 | 223 | if (this.transitioning || !d) return; 224 | this.transitioning = true; 225 | 226 | var g2 = this._display(this.grandparent,d), 227 | t1 = g1.transition().duration(750), 228 | t2 = g2.transition().duration(750); 229 | 230 | // Update the domain only after entering new elements. 231 | this.scales.x.domain([d.x, d.x + d.dx]); 232 | this.scales.y.domain([d.y, d.y + d.dy]); 233 | 234 | // Enable anti-aliasing during the transition. 235 | this.svg.style("shape-rendering", null); 236 | 237 | // Draw child nodes on top of parent nodes. 238 | this.svg.selectAll(".depth").sort(function(a, b) { return a.depth - b.depth; }); 239 | 240 | // Fade-in entering text. 241 | g2.selectAll("text").style("fill-opacity", 0); 242 | 243 | // Transition to the new view. 244 | t1.selectAll("text").call(this._text.bind(this)).style("fill-opacity", 0); 245 | t2.selectAll("text").call(this._text.bind(this)).style("fill-opacity", 1); 246 | t1.selectAll("rect").call(this._rect.bind(this)); 247 | t2.selectAll("rect").call(this._rect.bind(this)); 248 | 249 | // Remove the old node when the transition is finished. 250 | t1.remove().each("end", () => { 251 | this.svg.style("shape-rendering", "crispEdges"); 252 | this.transitioning = false; 253 | }); 254 | } 255 | 256 | 257 | // Aggregate the values for internal nodes. This is normally done by the 258 | // treemap layout, but not here because of our custom implementation. 259 | // We also take a snapshot of the original children (_children) to avoid 260 | // the children being overwritten when when layout is computed. 261 | _initialize(root) { 262 | root.x = root.y = 0; 263 | root.dx = this.props.width; 264 | root.dy = this.props.height; 265 | root.depth = 0; 266 | return root; 267 | } 268 | 269 | // Compute the treemap layout recursively such that each group of siblings 270 | // uses the same size (1×1) rather than the dimensions of the parent cell. 271 | // This optimizes the layout for the current zoom state. Note that a wrapper 272 | // object is created for the parent node for each group of siblings so that 273 | // the parent’s dimensions are not discarded as we recurse. Since each group 274 | // of sibling was laid out in 1×1, we must rescale to fit using absolute 275 | // coordinates. This lets us use a viewport to zoom. 276 | _layout(treemap,d) { 277 | if (d._children) { 278 | treemap.nodes({_children: d._children}); 279 | d._children.forEach((c) =>{ 280 | c.x = d.x + c.x * d.dx ; 281 | c.y = d.y + c.y * d.dy; 282 | c.dx *= d.dx; 283 | c.dy *= d.dy; 284 | c.parent = d; 285 | this._layout(treemap,c); 286 | }); 287 | } 288 | } 289 | 290 | _text(text) { 291 | var {x,y} = this.scales; 292 | text.attr("x", function(d) { return x(d.x) + 6; }) 293 | .attr("y", function(d) { return y(d.y) + 6; }); 294 | } 295 | 296 | _rect(rect) { 297 | var {x,y} = this.scales; 298 | rect.attr("x", function(d) { return x(d.x); }) 299 | .attr("y", function(d) { return y(d.y); }) 300 | .attr("width", function(d) { return x(d.x + d.dx) - x(d.x); }) 301 | .attr("height", function(d) { return y(d.y + d.dy) - y(d.y); }); 302 | } 303 | 304 | _name(d,last) { 305 | 306 | var back = ""; 307 | if(last == null && d.parent){ 308 | back = " > 回上一層" ; 309 | } 310 | if(d.parent ){ 311 | return this._name(d.parent,d) + " > " + d.name +" " + unitconverter.convert(d.value,null,false) +back; 312 | } 313 | return d.name + back; 314 | } 315 | 316 | 317 | destroy(el){ 318 | 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /views/controller/bubble-gov.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | // import rd3 from 'react-d3'; 5 | 6 | import D3BudgetBubble from './../components/d3BudgetBubble.jsx'; 7 | 8 | import CommentHelper from './../helpers/comment.jsx'; 9 | import Loading from './../components/Loading.jsx'; 10 | import FBComment from './../components/fb/FBComment.jsx'; 11 | var ReactDisqusThread = require('react-disqus-thread'); 12 | 13 | import cx from 'classnames'; 14 | import Util from '../helpers/util'; 15 | import Promise from "bluebird"; 16 | 17 | import BaseComponent from './../components/BaseComponent.jsx'; 18 | export default class Bubble extends BaseComponent { 19 | 20 | constructor(props) { 21 | super(props); 22 | this.state = { 23 | infoBudget:null, 24 | selectedBudget:null, 25 | groupKey:"topname", 26 | info_state:1 27 | }; 28 | 29 | if(global.window != null){ 30 | Promise.all([ 31 | Util.getBudgetInfos(this.props.budget_file_type,this.props.budget_links), 32 | Util.process_gov_type(this.props.budget_meta_links) 33 | ]).then(([res,gov_types])=>{ 34 | var last_res = res; 35 | if(gov_types){ 36 | var typeMap = {}; 37 | gov_types.forEach(function(type){ 38 | typeMap[type["代碼"]] = type; 39 | }); 40 | last_res = res.map((r)=>{ 41 | var gov_key = $.trim(r.code).substring(0,2); 42 | r.gov_type = typeMap[gov_key] && typeMap[gov_key]["名稱"]; 43 | r.gov_summary_type = typeMap[gov_key] && typeMap[gov_key]["大政式分類"] || r.gov_type; 44 | return r; 45 | }); 46 | } 47 | 48 | 49 | this.setState({ 50 | all_budget:last_res, 51 | last_budget:this.get_budget_data(this.state.groupKey,last_res), 52 | waiting:false 53 | }); 54 | }); 55 | 56 | } 57 | } 58 | 59 | get_budget_data(groupKey,datas){ 60 | if(groupKey=="topname"){ 61 | var data = datas.reduce(function(now,next){ 62 | var {year,code,amount,last_amount,name,topname,depname,depcat, 63 | category,ref,change,gov_type} = next; 64 | 65 | now[topname+"-"+depname] = now[topname+"-"+depname] || { 66 | year, 67 | code:"00"+code.substring(2,6)+"0000", 68 | amount:0, 69 | last_amount:0, 70 | name:null, 71 | topname, 72 | depname, 73 | depcat:null, 74 | category:null,ref,change:0,gov_type 75 | }; 76 | 77 | now[topname+"-"+depname].amount += amount; 78 | now[topname+"-"+depname].last_amount += last_amount; 79 | now[topname+"-"+depname].change += change; 80 | return now; 81 | },{}); 82 | 83 | return Object.keys(data).map((k)=> data[k] ); 84 | }else if(groupKey == "gov_type"){ 85 | var data = datas.reduce(function(now,next){ 86 | 87 | var {year,code,amount,last_amount,name,topname,depname,depcat, 88 | category,ref,change,gov_type,gov_summary_type} = next; 89 | 90 | if(code.substring(0,2)=="00"){ 91 | return now; 92 | } 93 | 94 | var nowkey = gov_summary_type+"-"+gov_type; 95 | now[nowkey] = now[nowkey] || { 96 | year, 97 | code:code.substring(0,2)+"00000000", 98 | amount:0, 99 | last_amount:0, 100 | name:null, 101 | topname:gov_summary_type, 102 | depname:gov_type, 103 | depcat:null, 104 | category:null, 105 | ref, 106 | change:0, 107 | gov_type, 108 | gov_summary_type 109 | }; 110 | 111 | now[nowkey].amount += amount; 112 | now[nowkey].last_amount += last_amount; 113 | now[nowkey].change += change; 114 | return now; 115 | },{}); 116 | 117 | return Object.keys(data).map((k)=> data[k] ); 118 | } 119 | } 120 | 121 | componentDidMount() { 122 | 123 | } 124 | 125 | componentDidUpdate() { 126 | 127 | } 128 | 129 | 130 | componentWillUnmount() { 131 | } 132 | 133 | onBudgetClick(d,cluster){ 134 | this.setState({selectedBudget:d}); 135 | $(document.body).addClass("modal-open"); 136 | // console.log(d,cluster); 137 | } 138 | 139 | onBudgetOver(d,cluster){ 140 | this.setState({infoBudget:d}); 141 | // console.log(d,cluster); 142 | } 143 | 144 | onChangeGroupKey(type){ 145 | // console.log(type); 146 | this.setState({groupKey:type, 147 | last_budget:this.get_budget_data(type,this.state.all_budget) 148 | }); 149 | } 150 | 151 | toogle_info_state(state){ 152 | this.setState({info_state:this.state.info_state == 1 ? 0 : 1}); 153 | } 154 | 155 | _name(d) { 156 | var out = []; 157 | if(d.topname){ 158 | out.push(d.topname); 159 | } 160 | if(d.depname){ 161 | out.push(d.depname); 162 | } 163 | if(d.category){ 164 | out.push(d.category); 165 | } 166 | if(d.name){ 167 | out.push(d.name); 168 | } 169 | 170 | return out.join(" > "); 171 | } 172 | 173 | cancelSelect(){ 174 | this.setState({selectedBudget:null}); 175 | $(document.body).removeClass("modal-open"); 176 | } 177 | 178 | render(){ 179 | // var {data,selectedDrill} = this.state; 180 | // var {drilldown} = data; 181 | var {selectedBudget,infoBudget} = this.state; 182 | 183 | var budgetComments = null; 184 | if(selectedBudget){ 185 | if(this.props.budget_id == 1){ 186 | budgetComments = ; 187 | }else if(this.props.budget_id <= 6){ 188 | budgetComments = 189 | }else{ 190 | budgetComments = ; 197 | } 198 | } 199 | 200 | return ( 201 |
    202 |
    203 | {this.state.last_budget && 204 |
    205 |
    206 | 207 | {this.state.last_budget && this.state.last_budget[0].gov_type && 208 | ()} 209 |
    210 | 212 |
    213 | } 214 | {this.state.last_budget == null && } 215 |
    216 | { 217 | infoBudget && 218 |
    233 | {selectedBudget == null && 234 |

    238 | } 239 | 240 | 241 |
    242 | {selectedBudget &&
     
    245 | } 246 |

    {this._name(infoBudget) }

    247 | {(this.state.info_state ==1 ||selectedBudget) && (
    248 | 249 |

    科目代碼:{infoBudget.code}

    250 |

    本年度預算:{unitconverter.convert(infoBudget.amount,null,false)}

    251 |

    前一年度預算:{unitconverter.convert(infoBudget.last_amount,null,false)} 252 | {infoBudget.change != null && infoBudget.change != 0 && 253 | {unitconverter.percent(infoBudget.change,infoBudget.last_amount)} 254 | } 255 |

    256 |
    257 |
    )} 258 | 259 |
    260 | 261 | {selectedBudget &&
    262 | 263 | {infoBudget.comment &&
    264 | 詳細資料 265 |
    266 |
    267 |
    268 |
    269 |
    270 |
    271 |
    272 | 273 |
    } 274 | 網友留言 275 |
    276 |
    277 | { 278 | budgetComments 279 | } 280 |
    281 |
    } 282 | 283 |
    284 | } 285 |
    286 | ); 287 | } 288 | } 289 | 290 | 291 | if(global.window != null){ 292 | React.render(React.createElement(Bubble,window.react_data), document.getElementById("react-root")); 293 | } 294 | 295 | -------------------------------------------------------------------------------- /views/controller/drilldown.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import unitconverter from "./../helpers/unitconverter.jsx"; 4 | // import rd3 from 'react-d3'; 5 | import BudgetTable from './../components/BudgetTable.jsx'; 6 | 7 | import BudgetTreeMap from './../components/d3BudgetTreemap.jsx'; 8 | import CommentHelper from './../helpers/comment.jsx'; 9 | 10 | import Util from '../helpers/util'; 11 | 12 | import BaseComponent from './../components/BaseComponent.jsx'; 13 | import BarChart from 'react-bar-chart'; 14 | import D3Radar from './../components/d3Radar.jsx'; 15 | import cx from 'classnames'; 16 | import Promise from "bluebird"; 17 | 18 | export default class Drilldown extends BaseComponent { 19 | 20 | constructor(props) { 21 | super(props); 22 | if(global.window != null){ 23 | Promise.all( 24 | [ 25 | Util.getBudgetInfos( 26 | this.props.budget_file_type, 27 | this.props.budget_links), 28 | Util.process_meta_link(this.props.budget_meta_links) 29 | ]).then(([res,meta])=>{ 30 | var data = this._dimensions(res,'topname','depname','category','name'); 31 | this.setState({ 32 | last_budget:res, 33 | sampleData: data, 34 | selectedDrill:data, 35 | codeMetas:meta 36 | }); 37 | 38 | var el = React.findDOMNode(this.refs.chart); 39 | 40 | var width = Math.min($(el).width(),$(window).width()); 41 | var height = 500; 42 | 43 | this.chart = new BudgetTreeMap(el, { 44 | width: width - 15, 45 | height: height, 46 | type:this.props.budget_file_type, 47 | // height: 1000, 48 | onOverBudget:(d) =>{ 49 | this.setState({selectedDrill:d}); 50 | }, 51 | onSelect:(d)=>{ 52 | this.setState({currentDrill:d}); 53 | } 54 | }, {root:data}); 55 | }); 56 | 57 | } 58 | 59 | this.state = {purpose_type:"radar"}; 60 | 61 | } 62 | 63 | doFocusDrill(drill){ 64 | this.setState({selectedDrill:drill}); 65 | } 66 | 67 | componentDidMount() { 68 | 69 | } 70 | 71 | componentDidUpdate() { 72 | var el = React.findDOMNode(this.refs.chart); 73 | // this.chart.update(el, this.getChartState()); 74 | } 75 | 76 | componentWillUnmount() { 77 | var el = React.findDOMNode(this.refs.chart); 78 | this.chart.destroy(el); 79 | } 80 | 81 | 82 | 83 | _dimension(items,item,key,pipe){ 84 | var groups = {}; 85 | 86 | var newchild = function(name,children){ 87 | return { 88 | name:name, 89 | children:children || [] 90 | }; 91 | } 92 | items.forEach(function(b){ 93 | groups[b[key]] = groups[b[key]] || newchild(b[key]); 94 | groups[b[key]].children.push(b); 95 | }); 96 | 97 | var childs = []; 98 | for(var k in groups){ 99 | childs.push(newchild(k,groups[k].children)); 100 | } 101 | 102 | item.children = childs; 103 | item.children.forEach(function(child){ 104 | pipe && pipe(child); 105 | }); 106 | 107 | return item; 108 | 109 | } 110 | 111 | _dimensions(items,k1,k2,k3,k4){ 112 | 113 | var newmap = { 114 | name:"總預算", 115 | children:[] 116 | }; 117 | var value = 0; 118 | this._dimension(items,newmap,k1,(item) => { 119 | this._dimension(item.children,item,k2,(item)=>{ 120 | 121 | this._dimension(item.children,item,k3,(item)=>{ 122 | item.children.forEach(function(item){ 123 | item.value = parseInt(item.amount,10); 124 | if(!isNaN(item.value)){ 125 | value += item.value ; 126 | } 127 | }); 128 | // return dimension(item.children,item,k4,(item)=>{ 129 | // var items = item.children; 130 | // delete items.children; 131 | // for(var k in items[0]){ 132 | // item[k]= items[0][k]; 133 | // } 134 | // delete item["comment"]; 135 | // // item.children = null 136 | // }); 137 | }); 138 | }); 139 | }); 140 | newmap._children = newmap.children; 141 | newmap.value = value; 142 | newmap.name = " 總預算 " + unitconverter.convert(value,null,false)+ " "; 143 | return newmap; 144 | } 145 | 146 | _drillName(drill,ignoreParent){ 147 | if(ignoreParent != true && drill.parent ){ 148 | return this._drillName(drill.parent) + " > " + drill.name ; 149 | } 150 | return drill.name ; 151 | } 152 | 153 | _drillSections(drill){ 154 | if(drill._children){ 155 | var i = 0 ; 156 | drill._children.forEach((d) =>{ 157 | i += this._drillSections(d); 158 | }); 159 | return i; 160 | } 161 | return 1; 162 | } 163 | 164 | _lookup_child_purpose(drill,codeMetas){ 165 | 166 | if(drill._purpose != null){ 167 | return drill._purpose; 168 | } 169 | 170 | var ret = {}; 171 | var purposes = []; 172 | var childrens = drill._children || drill.children; 173 | if(childrens && childrens.length){ 174 | childrens.forEach((child)=>{ 175 | if(child.code != null){ 176 | // debugger; 177 | } 178 | if(child.code && codeMetas[child.code]){ 179 | purposes.push(codeMetas[child.code]["purpose"]); 180 | }else{ 181 | purposes.push(this._lookup_child_purpose(child,codeMetas)); 182 | } 183 | }); 184 | } 185 | if(drill.code && codeMetas[drill.code]){ 186 | purposes.push(codeMetas[drill.code]["purpose"]); 187 | } 188 | purposes.forEach(function(purpose){ 189 | var ignorefield=["款","項","目","節","科目名稱","預算科目編號"]; 190 | for(var k in purpose){ 191 | 192 | if(ignorefield.filter((item)=> item == k).length ==0){ 193 | ret[k] = (ret[k] || 0) + (parseInt(purpose[k],10)); 194 | } 195 | } 196 | }); 197 | drill._purpose = ret; 198 | return ret; 199 | } 200 | 201 | _find_meta_details(drill,codeMetas){ 202 | if(codeMetas == null){ 203 | return null; 204 | } 205 | if(drill == null){ 206 | return null; 207 | } 208 | 209 | var metas = this._lookup_child_purpose(drill,codeMetas); 210 | var sortnum = function(name){ 211 | if(name.indexOf("_資") != -1){ 212 | return 2; 213 | } 214 | if(name.indexOf("_經") != -1){ 215 | return 3; 216 | } 217 | if(name.indexOf("小計") != -1){ 218 | return 4; 219 | } 220 | if(name.indexOf("合計") != -1){ 221 | return 5; 222 | } 223 | return 1; 224 | }; 225 | var okeys = Object.keys(metas); 226 | /* .sort(function(n1,n2){ 227 | return sortnum(n1) - sortnum(n2); 228 | }); 229 | */ 230 | 231 | return okeys.map((key)=>{ 232 | return {name:key,value:metas[key] * 1000}; 233 | }); 234 | } 235 | _drill_names(drill,ignoreParent){ 236 | if(drill == null){ 237 | return []; 238 | } 239 | //ignore root 240 | if(drill.parent == null){ 241 | return []; 242 | } 243 | if(ignoreParent != true && drill.parent ){ 244 | var d = drill; 245 | var out = []; 246 | while(d.parent){ 247 | out.unshift(d.name); 248 | d=d.parent; 249 | } 250 | return out; 251 | } 252 | return [drill.name] ; 253 | } 254 | onSelectPurposeType(type){ 255 | this.setState({purpose_type:type}) 256 | } 257 | 258 | render(){ 259 | 260 | var {data,codeMetas,selectedDrill,last_budget,currentDrill} = this.state; 261 | var meta_info = null; 262 | var currentRadarData = null; 263 | if(selectedDrill != null){ 264 | var meta_detail = this._find_meta_details(selectedDrill,codeMetas); 265 | meta_info = meta_detail;//.filter(k=>k.value > 0); 266 | currentRadarData = [[{axes:meta_detail.filter( 267 | k => { 268 | return k.name != "經常支出小計" && 269 | k.name != "資本支出小計" && 270 | k.name != "合計"; 271 | }).map((k)=>{ 272 | return {axis:k.name,value:k.value, 273 | yOffset: k.name == "預備金_經" ? 20:0, 274 | valueText:unitconverter.convert(k.value,null,false)} 275 | })}]]; 276 | } 277 | // var {drilldown} = data; 278 | return ( 279 |
    280 |
    281 |
    282 |
    283 | 284 |
    285 | 286 |
    287 |
    288 |
    289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 308 | 309 | 310 | 311 | 318 | 319 | { meta_info && ( 320 | 321 | 322 | 337 | 338 | )} 339 | 340 | 343 | 347 | 348 | 349 |
    預算 { selectedDrill && this._drillName(selectedDrill) }
    金額 298 |

    299 | 300 | { selectedDrill && unitconverter.convert(selectedDrill.value,null,false) } 301 | 302 |

    303 | {this.props.budget_file_type != 2 && 304 |

    (即為{ selectedDrill && unitconverter.convert(selectedDrill.value,-1,true) })

    305 | } 306 | 307 |
    佔總預算比例 312 |

    313 | 314 | { selectedDrill && ( parseInt(selectedDrill.value/this.state.sampleData.value *10000 ,10)/100+"%") } 315 | 316 |

    317 |
    用途別資料 323 | 327 | {this.state.purpose_type=="radar" && currentRadarData && } 328 | 329 | {this.state.purpose_type=="table" && meta_info.map((data)=>
    330 |

    {data.name}: 331 | 332 | 333 | 334 |

    335 |
    )} 336 |
    341 | 子項目資料 342 | 344 | 還有 { selectedDrill && selectedDrill._children && selectedDrill._children.length } 個子預算類別, 345 | 總計展開後有 { selectedDrill && this._drillSections(selectedDrill) } 個子預算項目。 346 |
    350 |
    351 |
    352 | {this.props.budget_file_type != 2 && 353 |
    354 |
    355 |
    356 | 357 |
    358 | } 359 |
    360 | ); 361 | } 362 | } 363 | 364 | if(global.window != null){ 365 | React.render(React.createElement(Drilldown,window.react_data), document.getElementById("react-root")); 366 | } 367 | 368 | -------------------------------------------------------------------------------- /public/js/radar-chart.js: -------------------------------------------------------------------------------- 1 | var RadarChart = { 2 | defaultConfig: { 3 | containerClass: 'radar-chart', 4 | w: 600, 5 | h: 600, 6 | factor: 0.95, 7 | factorLegend: 1, 8 | levels: 3, 9 | levelTick: false, 10 | TickLength: 10, 11 | maxValue: 0, 12 | minValue: 0, 13 | radians: 2 * Math.PI, 14 | color: d3.scale.category10(), 15 | axisLine: true, 16 | axisText: true, 17 | circles: true, 18 | radius: 5, 19 | backgroundTooltipColor: "#555", 20 | backgroundTooltipOpacity: "0.7", 21 | tooltipColor: "white", 22 | axisJoin: function(d, i) { 23 | return d.className || i; 24 | }, 25 | tooltipFormatValue: function(d) { 26 | return d; 27 | }, 28 | tooltipFormatClass: function(d) { 29 | return d; 30 | }, 31 | transitionDuration: 300 32 | }, 33 | chart: function() { 34 | // default config 35 | var cfg = Object.create(RadarChart.defaultConfig); 36 | function setTooltip(tooltip, msg){ 37 | if(msg == false || msg == undefined){ 38 | tooltip.classed("visible", 0); 39 | tooltip.select("rect").classed("visible", 0); 40 | }else{ 41 | tooltip.classed("visible", 1); 42 | 43 | var container = tooltip.node().parentNode; 44 | var coords = d3.mouse(container); 45 | 46 | tooltip.select("text").classed('visible', 1).style("fill", cfg.tooltipColor); 47 | var padding=5; 48 | var bbox = tooltip.select("text").text(msg).node().getBBox(); 49 | 50 | tooltip.select("rect") 51 | .classed('visible', 1).attr("x", 0) 52 | .attr("x", bbox.x - padding) 53 | .attr("y", bbox.y - padding) 54 | .attr("width", bbox.width + (padding*2)) 55 | .attr("height", bbox.height + (padding*2)) 56 | .attr("rx","5").attr("ry","5") 57 | .style("fill", cfg.backgroundTooltipColor).style("opacity", cfg.backgroundTooltipOpacity); 58 | tooltip.attr("transform", "translate(" + (coords[0]+10) + "," + (coords[1]-10) + ")") 59 | } 60 | } 61 | function radar(selection) { 62 | selection.each(function(data) { 63 | var container = d3.select(this); 64 | var tooltip = container.selectAll('g.tooltip').data([data[0]]); 65 | 66 | var tt = tooltip.enter() 67 | .append('g') 68 | .classed('tooltip', true) 69 | 70 | tt.append('rect').classed("tooltip", true); 71 | tt.append('text').classed("tooltip", true); 72 | 73 | // allow simple notation 74 | data = data.map(function(datum) { 75 | if(datum instanceof Array) { 76 | datum = {axes: datum}; 77 | } 78 | return datum; 79 | }); 80 | 81 | var maxValue = Math.max(cfg.maxValue, d3.max(data, function(d) { 82 | return d3.max(d.axes, function(o){ return o.value; }); 83 | })); 84 | maxValue -= cfg.minValue; 85 | 86 | var allAxis = data[0].axes.map(function(i, j){ return {name: i.axis, xOffset: (i.xOffset)?i.xOffset:0, yOffset: (i.yOffset)?i.yOffset:0}; }); 87 | var total = allAxis.length; 88 | var radius = cfg.factor * Math.min(cfg.w / 2, cfg.h / 2); 89 | var radius2 = Math.min(cfg.w / 2, cfg.h / 2); 90 | 91 | container.classed(cfg.containerClass, 1); 92 | 93 | function getPosition(i, range, factor, func){ 94 | factor = typeof factor !== 'undefined' ? factor : 1; 95 | return range * (1 - factor * func(i * cfg.radians / total)); 96 | } 97 | function getHorizontalPosition(i, range, factor){ 98 | return getPosition(i, range, factor, Math.sin); 99 | } 100 | function getVerticalPosition(i, range, factor){ 101 | return getPosition(i, range, factor, Math.cos); 102 | } 103 | 104 | // levels && axises 105 | var levelFactors = d3.range(0, cfg.levels).map(function(level) { 106 | return radius * ((level + 1) / cfg.levels); 107 | }); 108 | 109 | var levelGroups = container.selectAll('g.level-group').data(levelFactors); 110 | 111 | levelGroups.enter().append('g'); 112 | levelGroups.exit().remove(); 113 | 114 | levelGroups.attr('class', function(d, i) { 115 | return 'level-group level-group-' + i; 116 | }); 117 | 118 | var levelLine = levelGroups.selectAll('.level').data(function(levelFactor) { 119 | return d3.range(0, total).map(function() { return levelFactor; }); 120 | }); 121 | 122 | levelLine.enter().append('line'); 123 | levelLine.exit().remove(); 124 | 125 | if (cfg.levelTick){ 126 | levelLine 127 | .attr('class', 'level') 128 | .attr('x1', function(levelFactor, i){ 129 | if (radius == levelFactor) { 130 | return getHorizontalPosition(i, levelFactor); 131 | } else { 132 | return getHorizontalPosition(i, levelFactor) + (cfg.TickLength / 2) * Math.cos(i * cfg.radians / total); 133 | } 134 | }) 135 | .attr('y1', function(levelFactor, i){ 136 | if (radius == levelFactor) { 137 | return getVerticalPosition(i, levelFactor); 138 | } else { 139 | return getVerticalPosition(i, levelFactor) - (cfg.TickLength / 2) * Math.sin(i * cfg.radians / total); 140 | } 141 | }) 142 | .attr('x2', function(levelFactor, i){ 143 | if (radius == levelFactor) { 144 | return getHorizontalPosition(i+1, levelFactor); 145 | } else { 146 | return getHorizontalPosition(i, levelFactor) - (cfg.TickLength / 2) * Math.cos(i * cfg.radians / total); 147 | } 148 | }) 149 | .attr('y2', function(levelFactor, i){ 150 | if (radius == levelFactor) { 151 | return getVerticalPosition(i+1, levelFactor); 152 | } else { 153 | return getVerticalPosition(i, levelFactor) + (cfg.TickLength / 2) * Math.sin(i * cfg.radians / total); 154 | } 155 | }) 156 | .attr('transform', function(levelFactor) { 157 | return 'translate(' + (cfg.w/2-levelFactor) + ', ' + (cfg.h/2-levelFactor) + ')'; 158 | }); 159 | } 160 | else{ 161 | levelLine 162 | .attr('class', 'level') 163 | .attr('x1', function(levelFactor, i){ return getHorizontalPosition(i, levelFactor); }) 164 | .attr('y1', function(levelFactor, i){ return getVerticalPosition(i, levelFactor); }) 165 | .attr('x2', function(levelFactor, i){ return getHorizontalPosition(i+1, levelFactor); }) 166 | .attr('y2', function(levelFactor, i){ return getVerticalPosition(i+1, levelFactor); }) 167 | .attr('transform', function(levelFactor) { 168 | return 'translate(' + (cfg.w/2-levelFactor) + ', ' + (cfg.h/2-levelFactor) + ')'; 169 | }); 170 | } 171 | if(cfg.axisLine || cfg.axisText) { 172 | var axis = container.selectAll('.axis').data(allAxis); 173 | 174 | var newAxis = axis.enter().append('g'); 175 | if(cfg.axisLine) { 176 | newAxis.append('line'); 177 | } 178 | if(cfg.axisText) { 179 | newAxis.append('text'); 180 | } 181 | 182 | axis.exit().remove(); 183 | 184 | axis.attr('class', 'axis'); 185 | 186 | if(cfg.axisLine) { 187 | axis.select('line') 188 | .attr('x1', cfg.w/2) 189 | .attr('y1', cfg.h/2) 190 | .attr('x2', function(d, i) { return (cfg.w/2-radius2)+getHorizontalPosition(i, radius2, cfg.factor); }) 191 | .attr('y2', function(d, i) { return (cfg.h/2-radius2)+getVerticalPosition(i, radius2, cfg.factor); }); 192 | } 193 | 194 | if(cfg.axisText) { 195 | axis.select('text') 196 | .attr('class', function(d, i){ 197 | var p = getHorizontalPosition(i, 0.5); 198 | 199 | return 'legend ' + 200 | ((p < 0.4) ? 'left' : ((p > 0.6) ? 'right' : 'middle')); 201 | }) 202 | .attr('dy', function(d, i) { 203 | var p = getVerticalPosition(i, 0.5); 204 | return ((p < 0.1) ? '1em' : ((p > 0.9) ? '0' : '0.5em')); 205 | }) 206 | .text(function(d) { return d.name; }) 207 | .attr('x', function(d, i){ return d.xOffset+ (cfg.w/2-radius2)+getHorizontalPosition(i, radius2, cfg.factorLegend); }) 208 | .attr('y', function(d, i){ return d.yOffset+ (cfg.h/2-radius2)+getVerticalPosition(i, radius2, cfg.factorLegend); }); 209 | } 210 | } 211 | 212 | // content 213 | data.forEach(function(d){ 214 | d.axes.forEach(function(axis, i) { 215 | axis.x = (cfg.w/2-radius2)+getHorizontalPosition(i, radius2, (parseFloat(Math.max(axis.value - cfg.minValue, 0))/maxValue)*cfg.factor); 216 | axis.y = (cfg.h/2-radius2)+getVerticalPosition(i, radius2, (parseFloat(Math.max(axis.value - cfg.minValue, 0))/maxValue)*cfg.factor); 217 | }); 218 | }); 219 | var polygon = container.selectAll(".area").data(data, cfg.axisJoin); 220 | 221 | polygon.enter().append('polygon') 222 | .classed({area: 1, 'd3-enter': 1}) 223 | .on('mouseover', function (dd){ 224 | d3.event.stopPropagation(); 225 | container.classed('focus', 1); 226 | d3.select(this).classed('focused', 1); 227 | setTooltip(tooltip, cfg.tooltipFormatClass(dd.className)); 228 | }) 229 | .on('mouseout', function(){ 230 | d3.event.stopPropagation(); 231 | container.classed('focus', 0); 232 | d3.select(this).classed('focused', 0); 233 | setTooltip(tooltip, false); 234 | }); 235 | 236 | polygon.exit() 237 | .classed('d3-exit', 1) // trigger css transition 238 | .transition().duration(cfg.transitionDuration) 239 | .remove(); 240 | 241 | polygon 242 | .each(function(d, i) { 243 | var classed = {'d3-exit': 0}; // if exiting element is being reused 244 | classed['radar-chart-serie' + i] = 1; 245 | if(d.className) { 246 | classed[d.className] = 1; 247 | } 248 | d3.select(this).classed(classed); 249 | }) 250 | // styles should only be transitioned with css 251 | .style('stroke', function(d, i) { return cfg.color(i); }) 252 | .style('fill', function(d, i) { return cfg.color(i); }) 253 | .transition().duration(cfg.transitionDuration) 254 | // svg attrs with js 255 | .attr('points',function(d) { 256 | return d.axes.map(function(p) { 257 | return [p.x, p.y].join(','); 258 | }).join(' '); 259 | }) 260 | .each('start', function() { 261 | d3.select(this).classed('d3-enter', 0); // trigger css transition 262 | }); 263 | 264 | if(cfg.circles && cfg.radius) { 265 | 266 | var circleGroups = container.selectAll('g.circle-group').data(data, cfg.axisJoin); 267 | 268 | circleGroups.enter().append('g').classed({'circle-group': 1, 'd3-enter': 1}); 269 | circleGroups.exit() 270 | .classed('d3-exit', 1) // trigger css transition 271 | .transition().duration(cfg.transitionDuration).remove(); 272 | 273 | circleGroups 274 | .each(function(d) { 275 | var classed = {'d3-exit': 0}; // if exiting element is being reused 276 | if(d.className) { 277 | classed[d.className] = 1; 278 | } 279 | d3.select(this).classed(classed); 280 | }) 281 | .transition().duration(cfg.transitionDuration) 282 | .each('start', function() { 283 | d3.select(this).classed('d3-enter', 0); // trigger css transition 284 | }); 285 | 286 | var circle = circleGroups.selectAll('.circle').data(function(datum, i) { 287 | return datum.axes.map(function(d) { return [d, i]; }); 288 | }); 289 | 290 | circle.enter().append('circle') 291 | .classed({circle: 1, 'd3-enter': 1}) 292 | .on('mouseover', function(dd){ 293 | d3.event.stopPropagation(); 294 | setTooltip(tooltip, cfg.tooltipFormatValue(dd[0].valueText || dd[0].value)); 295 | //container.classed('focus', 1); 296 | //container.select('.area.radar-chart-serie'+dd[1]).classed('focused', 1); 297 | }) 298 | .on('mouseout', function(dd){ 299 | d3.event.stopPropagation(); 300 | setTooltip(tooltip, false); 301 | container.classed('focus', 0); 302 | //container.select('.area.radar-chart-serie'+dd[1]).classed('focused', 0); 303 | //No idea why previous line breaks tooltip hovering area after hoverin point. 304 | }); 305 | 306 | circle.exit() 307 | .classed('d3-exit', 1) // trigger css transition 308 | .transition().duration(cfg.transitionDuration).remove(); 309 | 310 | circle 311 | .each(function(d) { 312 | var classed = {'d3-exit': 0}; // if exit element reused 313 | classed['radar-chart-serie'+d[1]] = 1; 314 | d3.select(this).classed(classed); 315 | }) 316 | // styles should only be transitioned with css 317 | .style('fill', function(d) { return cfg.color(d[1]); }) 318 | .transition().duration(cfg.transitionDuration) 319 | // svg attrs with js 320 | .attr('r', cfg.radius) 321 | .attr('cx', function(d) { 322 | return d[0].x; 323 | }) 324 | .attr('cy', function(d) { 325 | return d[0].y; 326 | }) 327 | .each('start', function() { 328 | d3.select(this).classed('d3-enter', 0); // trigger css transition 329 | }); 330 | 331 | //Make sure layer order is correct 332 | var poly_node = polygon.node(); 333 | poly_node.parentNode.appendChild(poly_node); 334 | 335 | var cg_node = circleGroups.node(); 336 | cg_node.parentNode.appendChild(cg_node); 337 | 338 | // ensure tooltip is upmost layer 339 | var tooltipEl = tooltip.node(); 340 | tooltipEl.parentNode.appendChild(tooltipEl); 341 | } 342 | }); 343 | } 344 | 345 | radar.config = function(value) { 346 | if(!arguments.length) { 347 | return cfg; 348 | } 349 | if(arguments.length > 1) { 350 | cfg[arguments[0]] = arguments[1]; 351 | } 352 | else { 353 | d3.entries(value || {}).forEach(function(option) { 354 | cfg[option.key] = option.value; 355 | }); 356 | } 357 | return radar; 358 | }; 359 | 360 | return radar; 361 | }, 362 | draw: function(id, d, options) { 363 | var chart = RadarChart.chart().config(options); 364 | var cfg = chart.config(); 365 | 366 | d3.select(id).select('svg').remove(); 367 | d3.select(id) 368 | .append("svg") 369 | .attr("width", cfg.w) 370 | .attr("height", cfg.h) 371 | .datum(d) 372 | .call(chart); 373 | } 374 | }; -------------------------------------------------------------------------------- /views/components/d3BudgetBubble.jsx: -------------------------------------------------------------------------------- 1 | 2 | import d3 from 'd3'; 3 | import _ from 'underscore'; 4 | import React from "react"; 5 | import BaseComponent from './BaseComponent.jsx'; 6 | import unitconverter from "./../helpers/unitconverter.jsx"; 7 | import d3util from "./../helpers/d3.jsx"; 8 | 9 | import Loading from './Loading.jsx'; 10 | 11 | // Reference 12 | // http://www.delimited.io/blog/2013/12/19/force-bubble-charts-in-d3 13 | 14 | // Reference2 15 | // http://bl.ocks.org/mbostock/7882658 16 | 17 | export default class D3BudgetBubble1 extends BaseComponent { 18 | 19 | constructor(props){ 20 | super(props); 21 | 22 | this.state = { 23 | maxRadius: 40, 24 | minRadius: 6, 25 | padding: 10, // separation between same-color nodes 26 | clusterPadding: 10 27 | }; 28 | // { 29 | // root: this.state.sampleData 30 | // } 31 | // { 32 | // width: width - 15, 33 | // height: height, 34 | // // height: 1000, 35 | // onOverBudget:(d) =>{ 36 | // this.setState({selectedDrill:d}); 37 | // } 38 | // } 39 | 40 | // { 41 | // width: width - 15, 42 | // height: height, 43 | // // height: 1000, 44 | // onOverBudget:(d) =>{ 45 | // this.setState({selectedDrill:d}); 46 | // } 47 | // } 48 | 49 | 50 | } 51 | 52 | componentDidMount() { 53 | this._groupKey = this.props.groupKey; 54 | this._draw(this.props.groupKey); 55 | 56 | d3util._drawScaleReference(d3.select(React.findDOMNode(this.refs.scale))); 57 | } 58 | 59 | getCenters(items,varname){ 60 | var centers = {}; 61 | items.forEach((item)=>{ 62 | var center = centers[item[varname]] = centers[item[varname]] || { 63 | name:item[varname], 64 | d:item, 65 | items:0, 66 | amount:0 67 | }; 68 | 69 | if(centers[item[varname]].d.amount < item.amount){ 70 | center.d = item; 71 | } 72 | centers[item[varname]].items ++; 73 | centers[item[varname]].amount += item.amount; 74 | }); 75 | 76 | return centers; 77 | } 78 | 79 | // getCenter(item,varname,clusters){ 80 | // return clusters[item[varname]]; 81 | // } 82 | 83 | componentWillUpdate(nextProps, nextState){ 84 | 85 | if(nextProps && nextProps.groupKey && nextProps.groupKey != this.props.groupKey){ 86 | this._groupKey = nextProps.groupKey; 87 | } 88 | 89 | if(nextProps.items && nextProps.items.length != this.props.items.length){ 90 | // console.log("update"); 91 | this._update(this._groupKey,nextProps.items); 92 | } 93 | } 94 | 95 | 96 | onBudgetClick(d,cluster){ 97 | this.props.onBudgetClick && this.props.onBudgetClick(d,cluster); 98 | } 99 | 100 | onBudgetOver(d,cluster){ 101 | this.props.onBudgetOver && this.props.onBudgetOver(d,cluster); 102 | } 103 | 104 | _update(groupKey,items){ 105 | 106 | 107 | this._draw(groupKey,items); 108 | return true; 109 | 110 | var that = this; 111 | var el = React.findDOMNode(this); 112 | 113 | var width = $(el).width(), 114 | maxRadius = 40, 115 | minRadius = 6, 116 | padding = 10, // separation between same-color nodes 117 | clusterPadding = 10; 118 | 119 | var nowitems = items || this.props.items; 120 | var nodes = nowitems.map(item=> { 121 | var ret = {}; 122 | for(var k in item){ 123 | ret[k]= item[k]; 124 | } 125 | return ret; 126 | }); 127 | var clusters = this.getCenters(nodes,groupKey); 128 | 129 | this._clusters = clusters; 130 | 131 | var grid_height = Math.max(500,d3.max(Object.keys(clusters), 132 | (c)=> (clusters[c].items) / 10) * 20); 133 | var grid_width = Math.min(width,250); 134 | 135 | this._grid_width = grid_width; 136 | this._grid_height = grid_height; 137 | 138 | var horizonal = Math.floor(width / grid_width); 139 | if(horizonal <= 1 ){ 140 | horizonal = 1; 141 | grid_width = width; 142 | } 143 | 144 | var margin = {top:grid_height/2 + 100,left:Math.max(120,grid_width/2) }; 145 | 146 | var cluster_array = []; 147 | 148 | for(var k in clusters){ 149 | let clu = clusters[k]; 150 | cluster_array.push(clu); 151 | } 152 | cluster_array = cluster_array.sort((a,b) => b.amount - a.amount); 153 | 154 | cluster_array.forEach((cluster,ind)=>{ 155 | cluster.cluterIndex = ind; 156 | cluster.d.fixed = true; 157 | cluster.d.x = margin.left + grid_width * (cluster.cluterIndex % horizonal) ; 158 | cluster.d.y = margin.top + grid_height * (Math.floor(cluster.cluterIndex / horizonal)); 159 | }) 160 | 161 | // var color = d3.scale.category10() 162 | // .domain(d3.range(ind)); 163 | 164 | var scaleR = d3.scale.pow().exponent(0.5).domain( 165 | [d3.min(nodes,n => n.amount ),d3.max(nodes,n=> n.amount )] 166 | ).range([minRadius, maxRadius]); 167 | 168 | nodes.forEach(d =>{ 169 | if(d.fixed){ 170 | return true; 171 | } 172 | 173 | var cluster = clusters[d[groupKey]]; 174 | d.gx = cluster.cluterIndex % horizonal; 175 | d.gy = (Math.floor(cluster.cluterIndex / horizonal)); 176 | d.x = margin.left + grid_width * d.gx + ( grid_width/2); 177 | 178 | if(d.change == null){ 179 | d.y = margin.top + grid_height * d.gy ; 180 | }else if(d.change == 0 ){ 181 | d.y = margin.top + grid_height * d.gy; 182 | }else{ 183 | d.y = margin.top + grid_height * d.gy + (d.change > 0 ?(grid_height/2) : -1* (grid_height/2)) ; 184 | } 185 | 186 | return; 187 | }); 188 | 189 | var height = grid_height * (Math.ceil(cluster_array.length / horizonal)) ; 190 | 191 | var svg = d3.select(React.findDOMNode(this.refs.svg)) 192 | .attr("width", width) 193 | .attr("height", height); 194 | // var labels = svg.selectAll(".label") 195 | // .data(cluster_array); 196 | 197 | this._update_cluster_header(svg,cluster_array); 198 | 199 | // var lines = svg.selectAll(".lines").data(cluster_array); 200 | 201 | var update_circle = svg.selectAll("circle") 202 | .data(nodes); 203 | 204 | update_circle.attr("cx", d => d.x).attr("cy", d => d.y) 205 | 206 | this.force(update_circle,nodes,width,height,scaleR); 207 | 208 | 209 | 210 | } 211 | 212 | _update_cluster_header(svg,cluster_array){ 213 | 214 | var update = svg.selectAll(".lines") 215 | .data(cluster_array); 216 | 217 | update.select(".text-name") 218 | .text(function (d) { return d.name }) 219 | .attr("x",function({d}){ 220 | return d.x - this.getComputedTextLength() /2 ; 221 | }) 222 | .attr("y",({d}) => d.y - (this._grid_height/2) ); 223 | 224 | update.select(".text-amount") 225 | .text(function (d) { return unitconverter.convert(d.amount,null,false) }) 226 | .attr("x",function({d}){ 227 | return d.x - this.getComputedTextLength() /2 ; 228 | }) 229 | .attr("y",({d}) => d.y - (this._grid_height/2) + 30 ) 230 | .attr("class", "text-amount"); 231 | 232 | update.select(".line-0") 233 | .attr("x1",cluster => cluster.d.x - this._grid_width/2 + 20 ) 234 | .attr("y1",cluster => cluster.d.y - this._grid_height/2 + 50 ) 235 | .attr("x2",cluster => cluster.d.x + this._grid_width/2 - 20 ) 236 | .attr("y2",cluster => cluster.d.y - this._grid_height/2 + 50 ); 237 | 238 | update.select(".line-1") 239 | .attr("x1",cluster =>cluster.d.x - this._grid_width/2 + 20 ) 240 | .attr("y1",cluster =>cluster.d.y - this._grid_height/2 - 30 ) 241 | .attr("x2",cluster =>cluster.d.x + this._grid_width/2 - 20 ) 242 | .attr("y2",cluster =>cluster.d.y - this._grid_height/2 - 30 ); 243 | 244 | var lines = update.enter(); 245 | 246 | var g = lines.append("g") 247 | .attr("class","lines"); 248 | 249 | g.append("line") 250 | .attr("x1",cluster => cluster.d.x - this._grid_width/2 + 20 ) 251 | .attr("y1",cluster => cluster.d.y - this._grid_height/2 + 50 ) 252 | .attr("x2",cluster => cluster.d.x + this._grid_width/2 - 20 ) 253 | .attr("y2",cluster => cluster.d.y - this._grid_height/2 + 50 ) 254 | .style("stroke","black") 255 | .style("stroke-width","2") 256 | .attr("class", "line-0"); 257 | 258 | g.append("line") 259 | .attr("x1",cluster =>cluster.d.x - this._grid_width/2 + 20 ) 260 | .attr("y1",cluster =>cluster.d.y - this._grid_height/2 - 30 ) 261 | .attr("x2",cluster =>cluster.d.x + this._grid_width/2 - 20 ) 262 | .attr("y2",cluster =>cluster.d.y - this._grid_height/2 - 30 ) 263 | .style("stroke","black") 264 | .style("stroke-width","2") 265 | .attr("class", "line-1"); 266 | 267 | g.append("text") 268 | .style("font-size", "20px") 269 | .text(function (d) { return d.name }) 270 | .attr("x",function({d}){ 271 | return d.x - this.getComputedTextLength() /2 ; 272 | }) 273 | .attr("y",({d}) => d.y - (this._grid_height/2) ) 274 | .attr("class", "text-name"); 275 | 276 | g.append("text") 277 | .style("font-size", "20px") 278 | .text(function (d) { return unitconverter.convert(d.amount,null,false) }) 279 | .attr("x",function({d}){ 280 | return d.x - this.getComputedTextLength() /2 ; 281 | }) 282 | .attr("y",({d}) => d.y - (this._grid_height/2) + 30 ) 283 | .attr("class", "text-amount"); 284 | 285 | update.exit().remove(); 286 | 287 | 288 | } 289 | 290 | _draw(groupKey,items){ 291 | 292 | var that = this; 293 | var el = React.findDOMNode(this); 294 | 295 | 296 | var width = $(el).width(); 297 | 298 | var {maxRadius, minRadius, 299 | padding, clusterPadding } = this.state ; 300 | 301 | var nodes = (items || this.props.items).map(item=> { 302 | var ret = {}; 303 | for(var k in item){ 304 | ret[k]= item[k]; 305 | } 306 | return ret; 307 | }); 308 | var clusters = this.getCenters(nodes,groupKey); 309 | 310 | this._clusters = clusters; 311 | 312 | var grid_height = Math.max(500,d3.max(Object.keys(clusters), 313 | (c)=> (clusters[c].items) / 10) * 20); 314 | var grid_width = Math.min(width,250); 315 | 316 | this._grid_width = grid_width; 317 | this._grid_height = grid_height; 318 | 319 | var horizonal = Math.floor(width / grid_width); 320 | if(horizonal <= 1 ){ 321 | horizonal = 1; 322 | grid_width = width; 323 | } 324 | 325 | var margin = {top:grid_height/2 + 100,left:Math.max(120,grid_width/2) }; 326 | 327 | var cluster_array = []; 328 | 329 | for(var k in clusters){ 330 | let clu = clusters[k]; 331 | cluster_array.push(clu); 332 | } 333 | cluster_array = cluster_array.sort((a,b) => b.amount - a.amount); 334 | 335 | cluster_array.forEach((cluster,ind)=>{ 336 | cluster.cluterIndex = ind; 337 | cluster.d.fixed = true; 338 | cluster.d.x = margin.left + grid_width * (cluster.cluterIndex % horizonal) ; 339 | cluster.d.y = margin.top + grid_height * (Math.floor(cluster.cluterIndex / horizonal)); 340 | }) 341 | 342 | // var color = d3.scale.category10() 343 | // .domain(d3.range(ind)); 344 | 345 | var scaleR = d3.scale.pow().exponent(0.5).domain( 346 | [d3.min(nodes,n => n.amount ),d3.max(nodes,n=> n.amount )] 347 | ).range([minRadius, maxRadius]); 348 | 349 | nodes.forEach(d =>{ 350 | if(d.fixed){ 351 | return true; 352 | } 353 | 354 | var cluster = clusters[d[groupKey]]; 355 | d.gx = cluster.cluterIndex % horizonal; 356 | d.gy = (Math.floor(cluster.cluterIndex / horizonal)); 357 | d.x = margin.left + grid_width * d.gx + ( grid_width/2); 358 | 359 | if(d.change == null){ 360 | d.y = margin.top + grid_height * d.gy ; 361 | }else if(d.change == 0 ){ 362 | d.y = margin.top + grid_height * d.gy; 363 | }else{ 364 | d.y = margin.top + grid_height * d.gy + (d.change > 0 ?(grid_height/2) : -1* (grid_height/2)) ; 365 | } 366 | 367 | // d.px = d.x; 368 | // d.py = d.y; 369 | // cluster.x = 100; 370 | // cluster.y = 150; 371 | return; 372 | }); 373 | 374 | var height = grid_height * (Math.ceil(cluster_array.length / horizonal)) ; 375 | 376 | 377 | // Use the pack layout to initialize node positions. 378 | // d3.layout.pack() 379 | // .sort(null) 380 | // .size([width, height]) 381 | // .children(function(d) { return scaleR(parseInt(d.amount,10)); }) 382 | // .value(function(d) { return scaleR(parseInt(d.amount,10)); }) 383 | // .nodes({values: 384 | // d3.nest().key(function(d) { return d[that.props.groupKey]; }).entries(nodes) 385 | // }); 386 | 387 | var svg = d3.select(React.findDOMNode(this.refs.svg)) 388 | .attr("width", width) 389 | .attr("height", height); 390 | 391 | var color_scale = d3util.getChangeColorScale(); 392 | 393 | this._update_cluster_header(svg,cluster_array); 394 | 395 | svg.selectAll("circle").remove(); 396 | var node = svg.selectAll("circle") 397 | .data(nodes) 398 | .enter().append("circle") 399 | .attr("cx", d => d.x) 400 | .attr("cy", d => d.y) 401 | .attr("stroke-width",1) 402 | // .attr("stroke",(d)=>color(clusters[d[this.props.groupKey]].cluterIndex)) 403 | .attr("stroke","#a87f98") 404 | .attr("class", "budget-circle") 405 | .attr("r", d => scaleR(d.amount) ) 406 | .style("fill", (d) => { 407 | if(d.last_amount == null){ 408 | return color_scale(.25); 409 | } 410 | 411 | return color_scale(unitconverter._percent(d.change,d.last_amount)); 412 | }) 413 | .on("click",(d)=>{ this.onBudgetClick(d,this._clusters[d[this._groupKey]]); }) 414 | .on("mouseover",(d)=>{ this.onBudgetOver(d,this._clusters[d[this._groupKey]]); }); 415 | // return true; 416 | 417 | this.force(node,nodes,width,height,scaleR); 418 | 419 | } 420 | 421 | force(node,nodes,width,height,scaleR){ 422 | 423 | var {maxRadius, minRadius, 424 | padding, clusterPadding } = this.state ; 425 | 426 | this._nodeSvg = node; 427 | 428 | var force = d3.layout.force() 429 | .nodes(nodes) 430 | .size([width, height]) 431 | .gravity(.000003) 432 | .charge(0) 433 | .alpha(2) 434 | .on("tick", (e) => { 435 | tick(this._nodeSvg,this._nodes,this._clusters,this._groupKey,this._grid_width,e); 436 | }); 437 | this._nodes = nodes; 438 | force.start(); 439 | // var safe = 0; 440 | // while(force.alpha() > 0.01){ 441 | // force.tick(); 442 | // safe ++; 443 | // if(safe > 20){ 444 | // break; 445 | // } 446 | // } 447 | 448 | function tick(node,nodes,clusters,groupKey,grid_width,e) { 449 | node 450 | .each(cluster(clusters,groupKey,5 * e.alpha * e.alpha)) 451 | .each(collide(nodes,clusters,groupKey,grid_width,.05)) 452 | .attr("cx", function(d) { return d.x; }) 453 | .attr("cy", function(d) { return d.y; }); 454 | } 455 | 456 | // Move d to be adjacent to the cluster node. 457 | 458 | function cluster(clusters,groupKey,alpha) { 459 | return function(d) { 460 | var cluster = clusters[d[groupKey]]; 461 | 462 | var x = d.x - cluster.d.x, 463 | y = d.y - cluster.d.y, 464 | l = Math.sqrt(x * x + y * y), 465 | r = scaleR(d.amount) + scaleR(cluster.d.amount); 466 | 467 | // if(Math.abs(x) > 100 || Math.abs(y) > 100){ 468 | // alpha = 50; 469 | // } 470 | 471 | if (l != r && l != 0) { 472 | l = (l - r) / l * alpha; 473 | d.x -= x *= l; 474 | d.y -= y *= l; 475 | cluster.d.x += x; 476 | cluster.d.y += y; 477 | } 478 | }; 479 | } 480 | 481 | // Resolves collisions between d and all other circles. 482 | function collide(nodes,clusters,groupKey,grid_width,alpha) { 483 | var quadtree = d3.geom.quadtree(nodes); 484 | return function(d) { 485 | var r = scaleR(d.amount) + maxRadius + Math.max(padding, clusterPadding), 486 | nx1 = d.x - r, 487 | nx2 = d.x + r, 488 | ny1 = d.y - r, 489 | ny2 = d.y + r; 490 | quadtree.visit(function(quad, x1, y1, x2, y2) { 491 | if (quad.point && (quad.point !== d)) { 492 | var cluster = clusters[d[groupKey]]; 493 | var x = d.x - quad.point.x, 494 | y = d.y - quad.point.y, 495 | l = Math.sqrt(x * x + y * y), 496 | r = scaleR(d.amount) + scaleR(quad.point.amount) + 497 | (cluster.d === quad.point ? padding : clusterPadding); 498 | 499 | if(d.change != null && d.change != 0 ){ 500 | if(d.change > 0 ){ 501 | y += 15 ; 502 | }else{ 503 | y -= 15; 504 | } 505 | } 506 | if (l < r) { 507 | l = (l - r) / l * alpha; 508 | d.x -= x *= l; 509 | d.y -= y *= l; 510 | 511 | d.x = Math.max(Math.max(d.x,cluster.d.x - grid_width/2),20); 512 | d.x = Math.min(d.x,cluster.d.x + grid_width/2); 513 | quad.point.x += x; 514 | quad.point.y += y; 515 | } 516 | } 517 | return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1; 518 | }); 519 | }; 520 | } 521 | 522 | } 523 | 524 | render (){ 525 | return
    526 | 528 | 529 | 530 |
    ; 531 | } 532 | 533 | 534 | } 535 | 536 | 537 | --------------------------------------------------------------------------------