├── .gitignore ├── Gruntfile.coffee ├── LICENSE ├── README.md ├── dist ├── jquery.group.min.css └── jquery.group.min.js ├── package.json └── src ├── jquery.group.coffee └── jquery.group.sass /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .sass-cache 3 | .idea 4 | *.iml 5 | *.swp 6 | dist/jquery.group.css 7 | dist/jquery.group.js 8 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.initConfig 3 | licenseString: '/* jQuery Group | Copyright (c) Teijo Laine <%= grunt.template.today("yyyy") %> | Licenced under the MIT licence */' 4 | pkg: grunt.file.readJSON("package.json") 5 | watch: 6 | scripts: 7 | files: ["src/jquery.group.sass", "src/jquery.group.coffee"] 8 | tasks: ["default"] 9 | 10 | compass: 11 | dist: 12 | options: 13 | sassDir: "src" 14 | cssDir: "dist" 15 | raw: "preferred_syntax = :sass\n" 16 | 17 | jshint: 18 | options: 19 | jshintrc: ".jshintrc" 20 | 21 | with_overrides: 22 | options: 23 | asi: true 24 | curly: false 25 | strict: false 26 | predef: ["jQuery", "console"] 27 | 28 | files: 29 | src: ["src/jquery.bracket.js"] 30 | 31 | coffee: 32 | compile: 33 | files: 34 | "dist/jquery.group.js": "src/jquery.group.coffee" 35 | 36 | cssmin: 37 | dist: 38 | files: 39 | "dist/<%= pkg.name %>.min.css": "dist/<%= pkg.name %>.css" 40 | options: 41 | banner: '<%= licenseString %>' 42 | 43 | uglify: 44 | options: 45 | compress: true 46 | banner: '<%= licenseString %>\n' 47 | 48 | dist: 49 | files: 50 | "dist/<%= pkg.name %>.min.js": ["dist/<%= pkg.name %>.js"] 51 | 52 | grunt.loadNpmTasks "grunt-contrib-coffee" 53 | grunt.loadNpmTasks "grunt-contrib-uglify" 54 | grunt.loadNpmTasks "grunt-contrib-watch" 55 | grunt.loadNpmTasks "grunt-contrib-jshint" 56 | grunt.loadNpmTasks "grunt-contrib-compass" 57 | grunt.loadNpmTasks "grunt-css" 58 | grunt.registerTask "default", ["compass", "coffee", "uglify", "cssmin"] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013 Teijo Laine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | jQuery Group 2 | ============ 3 | 4 | This is a jQuery library for visualizing and managing group stages of 5 | tournaments. You can input a list of teams and the library will create 6 | round-robin matchups for each team. 7 | 8 | You can find mode information and demos from the project website at 9 | [aropupu.fi/group](http://www.aropupu.fi/group/). 10 | 11 | Download 12 | -------- 13 | 14 | Get latest pre-compiled version from `dist/`. 15 | 16 | Compile code 17 | ------------ 18 | 19 | If you wish to edit the code, install `npm`. When you have `npm` installed, 20 | run `npm install` in project root to install dependencies. 21 | 22 | Run `grunt` to compile and minify the project sources to dist directory. 23 | -------------------------------------------------------------------------------- /dist/jquery.group.min.css: -------------------------------------------------------------------------------- 1 | /* jQuery Group | Copyright (c) Teijo Laine 2013 | Licenced under the MIT licence */ 2 | .jqgroup.read-write{cursor:default}.jqgroup.read-write .match{cursor:move}.jqgroup.read-write .match:hover{background-color:#ccc}.jqgroup.read-write .rounds .round{padding-bottom:10px}.jqgroup.read-write .rounds .round:first-child{background-color:#f99}.jqgroup.read-write .rounds .round:first-child .match{background-color:#fcc}.jqgroup.read-write .rounds .round:first-child .match .team{border-color:#faa}.jqgroup.read-write .standings table td:first-child{padding:0}.jqgroup.read-write .standings table td:first-child:hover:before{color:#333;font-size:1em;content:"\0270E";position:absolute;left:-1em}.jqgroup.read-write .rounds input[type=text].score:focus,.jqgroup.read-write .standings input[type=text]:focus{text-decoration:none;background-color:#fff;border:1px solid #000;color:#000}.jqgroup.read-write .rounds input[type=text].score.conflict,.jqgroup.read-write .rounds input[type=text].score.add.conflict,.jqgroup.read-write .standings input[type=text].conflict,.jqgroup.read-write .standings input[type=text].add.conflict{background-color:#900;color:#fee}.jqgroup{font-family:Arial;font-size:14px;width:100%;display:inline-block;box-sizing:border-box}.jqgroup input{box-sizing:border-box;height:22px}.jqgroup .standings{white-space:nowrap;overflow:hidden}.jqgroup .standings table{border-spacing:0;width:100%}.jqgroup .standings table td,.jqgroup .standings table th{padding:3px}.jqgroup .standings table td:nth-child(6){cursor:help}.jqgroup .standings table td:nth-child(6):hover{background-color:rgba(255,255,255,.6)}.jqgroup .standings table td:first-child{position:relative}.jqgroup .standings input[type=submit]{width:100%}.jqgroup .standings input[type=text]{margin:0;padding:3px;border:0;width:100%;background-color:transparent}.jqgroup .standings input[type=text].add{border:1px solid #000}.jqgroup .standings td:not(:first-child){text-align:center;background-color:rgba(255,255,255,.3)}.jqgroup .standings th:first-child{text-align:left}.jqgroup .standings td{border-top:1px solid #ddd}.jqgroup .standings .drop{cursor:pointer;background-color:#fee}.jqgroup .standings .drop:hover{background-color:red}.jqgroup .rounds{background-color:#ddd;border-top:1px solid #999;float:left;width:100%}.jqgroup .roundsHeader{cursor:pointer}.jqgroup .roundsHeader:hover{text-decoration:underline}.jqgroup .unassigned{background-color:#aaa}.jqgroup .participant{background-color:#eee}.jqgroup .match{background-color:#ddd;min-width:50px;float:left;clear:both;width:100%}.jqgroup .match:nth-child(n+3) div.team{border-top:1px solid #ccc}.jqgroup .match:nth-child(n+3) div.team.highlight{border-color:#0b0}.jqgroup .match div.team{float:left;width:50%}.jqgroup .match .score,.jqgroup .match div.label{box-sizing:border-box;padding:3px;float:left}.jqgroup .match .score{font-size:14px;height:22px;width:20%;border:0;text-align:center;background-color:rgba(255,255,255,.3)}.jqgroup .match .score.win{color:#060}.jqgroup .match .score.lose{color:#900}.jqgroup .match div.label{height:22px;display:inline-block;white-space:nowrap;overflow:hidden;width:80%}.jqgroup .round{width:100%;float:left;clear:both}.jqgroup .round:nth-child(n+3){margin-top:10px}.jqgroup .round.droppable{background-color:#8c8}.jqgroup .round.droppable .match{opacity:.7}.jqgroup .round.droppable.over{background-color:#8f8}.jqgroup [data-roundid="0"]{display:none}.jqgroup header{padding:0 2px;font-size:13px;display:block;text-align:center}.jqgroup .highlight{background-color:#3c0} -------------------------------------------------------------------------------- /dist/jquery.group.min.js: -------------------------------------------------------------------------------- 1 | /* jQuery Group | Copyright (c) Teijo Laine 2013 | Licenced under the MIT licence */ 2 | (function(){!function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A;return t={win:3,tie:1,loss:0},p=function(a){return a-1+a%2},z=function(a){var b;return o.test(a)?(b=parseInt(a),isNaN(b)?null:b):null},d=function(b){return a(b.currentTarget)},c=function(a){return[a,d(a)]},y=function(a,b){return a.findIndex(function(a){return a.id===b.id})},A=function(a){return{teams:a.participants.value().map(function(a){return{id:a.id,name:a.name}}),matches:a.matches.map(function(b){return{a:{team:y(a.participants,b.a.team),score:b.a.score},b:{team:y(a.participants,b.b.team),score:b.b.score},round:b.round}}).value()}},j=function(a,b){return a.map(function(a){var c,d,e,f,g,h;return d=b.filter(function(a){return null!==a.a.score&&null!==a.b.score}).filter(function(b){return b.a.team===a||b.b.team===a}).map(function(b){return b.a.team===a?{ownScore:b.a.score,opponentScore:b.b.score}:{ownScore:b.b.score,opponentScore:b.a.score}}),f=d.reduce(function(a,b){return a+b.ownScore},0),e=d.reduce(function(a,b){return a+b.opponentScore},0),h=d.filter(function(a){return a.ownScore>a.opponentScore}).size(),c=d.filter(function(a){return a.ownScore {{#each this}} '+v+" {{/each}}
WLTP±
{{#if team.label}}{{team.label}}{{else}}{{team.name}}{{/if}}
"),u=Handlebars.compile('
{{#each this}} '+v+' {{/each}}
WLTP±
'),q=Handlebars.compile('
{{#if this}}
Round {{this}}
{{else}}
Unassigned
{{/if}}
'),m=Handlebars.compile('
{{a.team.label}}
{{a.score}}
{{b.score}}
{{b.team.label}}
'),k=Handlebars.compile('
{{a.team.name}}
{{b.team.name}}
'),l=function(b,c){var d,e;return e=b.a.score>b.b.score,d={homeClass:e?"win":"lose",awayClass:e?"lose":"win"},a(c(_.extend(d,b)))},r=Handlebars.compile('
Rounds
'),s=Handlebars.compile('
'),b=function(a){return a.name},h=0,e=function(){return++h},i=0,f=function(){return++i},x=function(b,c){return function(){var d;return d=a(this).attr("data-teamid"),b.find("[data-teamid="+d+"]").toggleClass("highlight",c)}},g=function(b,g,h,i){var n,o,t,v,y,B,C,D,E,F,G,H,I,J,K,L,M,N,O;return N=function(a){return b.find("[data-roundid='"+a+"']")},v=function(a){return b.find("[data-matchid='"+a+"']")},i&&b.addClass("read-write"),O=function(){return{standings:function(c,e,g,h){var j,k,l,m,n,o;return h=h||_([]),i?(n=a(u(h.value())),k=n.find("input[type=submit]"),m=n.find("input").asEventStream("keyup").map(d).map(function(a){var b,c,d;return d=a.val(),b=a.attr("data-prev"),c=d.length>0&&(!h.map(function(a){return a.team}).pluck("name").contains(d)||b===d),{el:a,value:d,valid:c}}).toProperty(),m.onValue(function(a){return a.el.toggleClass("conflict",!a.valid),a.el.hasClass("add")?a.valid?k.removeAttr("disabled"):k.attr("disabled","disabled"):void 0}),l=m.map(function(a){return a.valid}).toProperty(),n.find("input.name").asEventStream("change").filter(l).map(".target").map(a).onValue(function(a){return e.push({id:parseInt(a.attr("data-teamid")),to:a.val()}),a.attr("data-prev",a.val())}),n.find("input.add").asEventStream("change").filter(l).map(".target").map(a).map(function(a){return a.val()}).onValue(function(a){return c.push({id:f(),name:a})}),n.find("td.drop").asEventStream("click").map(".target").map(a).map(function(a){return a.attr("data-name")}).onValue(function(a){return g.push(parseInt(a))}),n):(j=a(w(h.value())),o=x.bind(null,b),j.find("[data-teamid]").hover(o(!0),o(!1)),j)},roundsHeader:function(b){var c;return c=a(r()),c.asEventStream("click").onValue(function(){return b.toggle()}),c},rounds:a(s()),round:function(b){return a(q(b))},matchEdit:function(a){return l(a,k)},matchView:function(a){return l(a,m)}}}(),t=function(){return{create:function(a,b){return new function(){var e,f;f=O.round(b),this.markup=f,i&&(e=0,f.asEventStream("dragover").doAction(".preventDefault").onValue(function(){}),f.asEventStream("dragenter").doAction(".preventDefault").map(d).onValue(function(a){0===e&&a.addClass("over"),e++}),f.asEventStream("dragleave").doAction(".preventDefault").map(d).onValue(function(a){e--,0===e&&a.removeClass("over")}),f.asEventStream("drop").doAction(".preventDefault").map(c).onValues(function(b,c){var d,f;e=0,d=b.originalEvent.dataTransfer.getData("Text"),f=v(d),c.append(f),c.removeClass("over"),a.push({match:parseInt(d),round:parseInt(c.attr("data-roundId"))})}))}}}}(),o=function(){return{create:function(e,f){return new function(){var g;return f=a.extend({},f),f.draggable=(null!=i).toString(),i?(g=O.matchEdit(f),this.markup=g,g.find("input").asEventStream("keyup").map(d).onValue(function(a){return a.toggleClass("conflict",null===z(a.val()))}),g.find("input").asEventStream("change").onValue(function(){var a,b,c;return a=z(g.find("input.home").val()),b=z(g.find("input.away").val()),null!==a&&null!==b?(c={a:{team:f.a.team,score:a},b:{team:f.b.team,score:b}},e.push(c)):void 0}),g.asEventStream("dragstart").map(".originalEvent").map(c).onValues(function(a,c){return a.dataTransfer.setData("Text",f.id),c.css("opacity",.5,""),b.find(".round").addClass("droppable")}),g.asEventStream("dragend").map(".originalEvent").map(c).onValues(function(a,c){return c.removeAttr("style"),b.find(".droppable").removeClass("droppable")}),void 0):(this.markup=O.matchView(f),void 0)}}}}(),B=new Bacon.Bus,H=new Bacon.Bus,J=new Bacon.Bus,L=new Bacon.Bus,C=new Bacon.Bus,I=new Bacon.Bus,y=B.toProperty({participants:_([]),matches:_([])}),n=O.rounds.append(a(t.create(C,0).markup)),b.append(O.standings(H)).append(O.roundsHeader(n)).append(n),D=y.sampledBy(H,function(a,b){var c,d;return a.participants.size()>0&&(c=a.participants.map(function(a){return{id:e(),a:{team:a,score:null},b:{team:b,score:null}}}),a.matches=a.matches.union(c.value())),a.participants.push(b),d=p(a.participants.size()),_(_.range(n.find(".round").length-1,d)).each(function(a){return n.append(t.create(C,a+1).markup)}),a}),F=y.sampledBy(I,function(a,b){var c,d,e;return a.matches.filter(function(a){return a.a.team.id===b||a.b.team.id===b}).map(function(a){return a.id}).forEach(function(a){return v(a).remove()}),e=p(a.participants.size()),a.participants=a.participants.filter(function(a){return a.id!==b}),d=p(a.participants.size()),a.matches=a.matches.filter(function(a){return a.a.team.id!==b&&a.b.team.id!==b}).map(function(a){return a.round>d&&(a.round=0),a}),c=N(0),_(_.range(d+1,e+1)).each(function(a){var b,d;return d=N(a),b=d.find(".match"),c.append(b),d.remove()}),a}),M=y.sampledBy(L,function(a,b){return a.matches=a.matches.map(function(a){return a.a.team.id===b.a.team.id&&a.b.team.id===b.b.team.id?(void 0!==b.round&&(a.round=b.round),a.a.score=b.a.score,a.b.score=b.b.score):a.a.team.id===b.b.team.id&&a.b.team.id===b.a.team.id&&(void 0!==b.round&&(a.round=b.round),a.a.score=b.b.score,a.b.score=b.a.score),a}),a}),G=y.sampledBy(J,function(a,b){return a.participants=a.participants.map(function(a){return a.id===b.id&&(a.name=b.to),a}),a}),E=y.sampledBy(C,function(a,b){return a.matches=a.matches.map(function(a){return a.id===b.match&&(a.round=b.round),a}),a}),K=Bacon.mergeAll([D,M,G,F,E]),K.throttle(10).onValue(function(a){return i?i(A(a)):void 0}),D.merge(M).merge(F).throttle(10).onValue(function(a){return b.find(".standings").replaceWith(O.standings(H,J,I,j(a.participants,a.matches),null))}),G.merge(D).merge(M).throttle(10).onValue(function(a){var b,c,d;return c=a.matches.filter(function(a){return a.round}),d=a.matches.filter(function(a){return!a.round}),c.each(function(a){var b,c;return b=v(a.id),c=o.create(L,a).markup,b.length?b.replaceWith(c):N(a.round).append(c)}),d.size()>0||i?(b=N(0),b.show(),d.each(function(a){var c,d;return c=v(a.id),d=o.create(L,a).markup,c.length?c.replaceWith(d):b.append(d)})):void 0}),g.each(function(a){return H.push(a)}),h.each(function(a){return L.push(a)})},n={init:function(c){var d,e,f,h;return c=c||{},e=c.labeler||b,d=this,h=_(),f=_(),c.init&&(h=_(c.init.teams).map(function(a){return a.label=new Handlebars.SafeString(e(a)),a}),f=_(c.init.matches).map(function(a){return a.a.team=c.init.teams[a.a.team],a.b.team=c.init.teams[a.b.team],a})),g(a('
').appendTo(d),h,f,c.save||null)}},a.fn.group=function(b){return n[b]?n[b].apply(this,Array.prototype.slice.call(arguments,1)):"object"!=typeof b&&b?a.error("Method "+b+" does not exist on jQuery.group"):n.init.apply(this,arguments)}}(jQuery)}).call(this); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery.group", 3 | "version": "0.0.1", 4 | "repository" : { 5 | "type": "git", 6 | "url": "https://github.com/teijo/jquery-group.git" 7 | }, 8 | "dependencies": { 9 | "grunt-cli": ">= 0.1.9", 10 | "grunt": ">= 0.4.1", 11 | "grunt-contrib-uglify": ">= 0.2.4", 12 | "grunt-contrib-concat": ">= 0.3.0", 13 | "grunt-contrib-watch": ">= 0.3.1", 14 | "grunt-contrib-jshint": ">= 0.3.0", 15 | "grunt-shell": ">= 0.5.0", 16 | "grunt-css": ">= 0.5.4", 17 | "coffee-script": ">= 1.6.3", 18 | "grunt-contrib-coffee": ">= 0.7.0", 19 | "grunt-contrib-compass": ">= 0.5.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/jquery.group.coffee: -------------------------------------------------------------------------------- 1 | # jQuery Group 2 | # 3 | # Copyright (c) 2013, Teijo Laine, 4 | # http://aropupu.fi/group/ 5 | # 6 | # Licenced under the MIT licence 7 | 8 | (($) -> 9 | scoringScheme = 10 | win: 3 11 | tie: 1 12 | loss: 0 13 | 14 | # 2n teams -> n-1 rounds, 2n+1 teams -> n rounds 15 | roundCount = (participantCount) -> 16 | participantCount - 1 + (participantCount % 2) 17 | 18 | toIntOrNull = (string) -> 19 | return null unless numberRe.test(string) 20 | value = parseInt(string) 21 | (if isNaN(value) then null else value) 22 | 23 | evTarget = (ev) -> 24 | $ ev.currentTarget 25 | 26 | evElTarget = (ev) -> 27 | [ev, evTarget(ev)] 28 | 29 | teamPositionFromMatch = (participants, team) -> 30 | participants.findIndex((p) -> p.id == team.id) 31 | 32 | unwrap = (state) -> 33 | teams: state.participants.value().map((team) -> 34 | id: team.id 35 | name: team.name 36 | format: team.format ? "" 37 | data: team.data ? {} 38 | ) 39 | matches: state.matches.map((match) -> 40 | # Create all new object, mutating match breaks internal state 41 | a: 42 | team: teamPositionFromMatch(state.participants, match.a.team) 43 | score: match.a.score 44 | b: 45 | team: teamPositionFromMatch(state.participants, match.b.team) 46 | score: match.b.score 47 | round: match.round 48 | ).value() 49 | 50 | makeStandings = (participants, pairs) -> 51 | participants.map((it) -> 52 | matches = pairs.filter((match) -> 53 | match.a.score isnt null and match.b.score isnt null 54 | ).filter((match) -> 55 | match.a.team is it or match.b.team is it 56 | ).map((match) -> 57 | if match.a.team is it 58 | ownScore: match.a.score 59 | opponentScore: match.b.score 60 | else 61 | ownScore: match.b.score 62 | opponentScore: match.a.score 63 | ) 64 | roundWins = matches.reduce(((acc, match) -> 65 | acc + match.ownScore 66 | ), 0) 67 | roundLosses = matches.reduce(((acc, match) -> 68 | acc + match.opponentScore 69 | ), 0) 70 | wins = matches.filter((match) -> 71 | match.ownScore > match.opponentScore 72 | ).size() 73 | losses = matches.filter((match) -> 74 | match.ownScore < match.opponentScore 75 | ).size() 76 | ties = matches.filter((match) -> 77 | match.ownScore is match.opponentScore 78 | ).size() 79 | team: it 80 | wins: wins 81 | losses: losses 82 | ties: ties 83 | points: wins * scoringScheme.win + ties * scoringScheme.tie + losses * scoringScheme.loss 84 | roundWins: roundWins 85 | roundLosses: roundLosses 86 | ratio: roundWins - roundLosses 87 | ).sortBy((it) -> 88 | -it.ratio 89 | ).sortBy (it) -> 90 | -it.points 91 | 92 | numberRe = new RegExp(/^[0-9]+$/) 93 | 94 | standingsScoreColumnMarkup = ' 95 | {{wins}} 96 | {{losses}} 97 | {{ties}} 98 | {{points}} 99 | {{ratio}}' 100 | 101 | standingsViewTemplate = Handlebars.compile(' 102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | {{#each this}} 110 | '+standingsScoreColumnMarkup+' 111 | {{/each}} 112 |
WLTP±
{{#if team.label}}{{team.label}}{{else}}{{team.name}}{{/if}}
113 |
') 114 | 115 | standingsEditTemplate = Handlebars.compile(' 116 |
117 | 118 | 119 | 120 | 121 | 122 | 123 | {{#each this}} 124 | '+standingsScoreColumnMarkup+' 125 | {{/each}} 126 | 127 |
WLTP±
128 |
') 129 | 130 | roundTemplate = Handlebars.compile(' 131 |
132 | {{#if this}} 133 |
Round {{this}}
134 | {{else}} 135 |
Unassigned
136 | {{/if}} 137 |
') 138 | 139 | matchViewTemplate = Handlebars.compile(' 140 |
141 |
142 |
{{a.team.label}}
143 |
{{a.score}}
144 |
145 |
146 |
{{b.score}}
147 |
{{b.team.label}}
148 |
149 |
') 150 | 151 | matchEditTemplate = Handlebars.compile(' 152 |
153 |
154 |
{{a.team.label}}
155 | 156 |
157 |
158 | 159 |
{{b.team.label}}
160 |
161 |
') 162 | 163 | matchTemplate = (match, template) -> 164 | homeWins = match.a.score > match.b.score 165 | classes = 166 | homeClass: if homeWins then "win" else "lose" 167 | awayClass: if homeWins then "lose" else "win" 168 | $(template(_.extend(classes, match))) 169 | 170 | roundsHeaderTemplate = Handlebars.compile(' 171 |
Rounds
') 172 | 173 | roundsTemplate = Handlebars.compile('
') 174 | 175 | defaultLabeler = (team) -> 176 | team.name 177 | 178 | # If attached to backend, these functions could be overridden and return newly 179 | # allocated identifier via Ajax query. For standalone purposes, we can just 180 | # increment the integer. 181 | localMatchCounter = 0 182 | generateNewMatchId = () -> 183 | ++localMatchCounter 184 | 185 | localTeamCounter = 0 186 | generateNewTeamId = () -> 187 | ++localTeamCounter 188 | 189 | initLocalTeamCounter = (participants) -> 190 | localTeamCounter = if participants.size() > 0 then participants.max("id").value().id else 0 191 | 192 | teamHover = ($container, enabled) -> 193 | () -> 194 | teamId = $(@).attr("data-teamid") 195 | $container.find("[data-teamid=#{teamId}]").toggleClass("highlight", enabled) 196 | 197 | group = ($container, participants, pairs, onchange, labeler) -> 198 | 199 | roundById = (id) -> 200 | $container.find("[data-roundid='#{id}']") 201 | 202 | matchById = (id) -> 203 | $container.find("[data-matchid='#{id}']") 204 | 205 | $container.addClass "read-write" if onchange 206 | templates = (-> 207 | standings: (participantStream, renameStream, removeStream, participants) -> 208 | participants = participants or _([]) 209 | if !onchange 210 | $markup = $(standingsViewTemplate(participants.value())) 211 | over = teamHover.bind(null, $container) 212 | $markup.find("[data-teamid]").hover over(true), over(false) 213 | return $markup 214 | markup = $(standingsEditTemplate(participants.value())) 215 | $submit = markup.find("input[type=submit]") 216 | 217 | keyUps = markup.find("input").asEventStream("keyup").map(evTarget).map(($el) -> 218 | value = $el.val() 219 | previous = $el.attr("data-prev") 220 | valid = value.length > 0 and (not participants.map((it) -> it.team).pluck("name").contains(value) or previous is value) 221 | el: $el 222 | value: value 223 | valid: valid 224 | ).toProperty() 225 | 226 | keyUps.onValue (state) -> 227 | state.el.toggleClass "conflict", not state.valid 228 | if state.el.hasClass("add") 229 | if state.valid 230 | $submit.removeAttr "disabled" 231 | else 232 | $submit.attr "disabled", "disabled" 233 | 234 | isValid = keyUps.map((state) -> 235 | state.valid 236 | ).toProperty() 237 | 238 | inputChanges = (type) -> 239 | markup.find("input." + type).asEventStream("change").filter(isValid).map(".target").map($) 240 | 241 | inputChanges("name").onValue (el) -> 242 | renameStream.push 243 | id: parseInt(el.attr("data-teamid")) 244 | to: el.val() 245 | el.attr "data-prev", el.val() 246 | 247 | inputChanges("add").map((el) -> 248 | el.val() 249 | ).onValue (value) -> 250 | participantStream.push 251 | id: generateNewTeamId() 252 | name: value 253 | format: "" 254 | data: {} 255 | 256 | markup.find("td.drop").asEventStream("click").map(".target").map($).map((el) -> 257 | el.attr("data-name") 258 | ).onValue (value) -> 259 | removeStream.push parseInt(value) 260 | 261 | markup 262 | 263 | roundsHeader: ($rounds) -> 264 | tmpl = $(roundsHeaderTemplate()) 265 | tmpl.asEventStream("click").onValue () -> $rounds.toggle() 266 | tmpl 267 | rounds: $(roundsTemplate()) 268 | round: (roundNumber) -> $(roundTemplate(roundNumber)) 269 | matchEdit: (match) -> 270 | matchTemplate(match, matchEditTemplate) 271 | matchView: (match) -> 272 | matchTemplate(match, matchViewTemplate) 273 | )() 274 | 275 | Round = (-> 276 | create: (moveStream, round) -> 277 | new -> 278 | r = templates.round(round) 279 | @markup = r 280 | 281 | unless onchange 282 | return 283 | 284 | # Browser compatible hack for ignoring child objects' enter/leave 285 | # events http://stackoverflow.com/a/10906204 286 | eventCounter = 0 287 | 288 | round = (ev) -> 289 | r.asEventStream(ev).doAction(".preventDefault") 290 | 291 | round("dragover").onValue((ev) -> ) 292 | round("dragenter").map(evTarget) 293 | .onValue ($el) -> 294 | if eventCounter == 0 295 | $el.addClass "over" 296 | eventCounter++ 297 | return 298 | 299 | round("dragleave").map(evTarget) 300 | .onValue ($el) -> 301 | eventCounter-- 302 | if eventCounter == 0 303 | $el.removeClass "over" 304 | return 305 | 306 | round("drop").map(evElTarget).onValues (ev, $el) -> 307 | eventCounter = 0 308 | id = ev.originalEvent.dataTransfer.getData("Text") 309 | obj = matchById(id) 310 | $el.append obj 311 | $el.removeClass "over" 312 | moveStream.push 313 | match: parseInt(id) 314 | round: parseInt($el.attr("data-roundId")) 315 | return 316 | return 317 | )() 318 | 319 | Match = (-> 320 | create: (resultStream, match) -> 321 | new -> 322 | match = $.extend({}, match) 323 | match.draggable = (onchange?).toString() 324 | 325 | unless onchange 326 | @markup = templates.matchView(match) 327 | return 328 | 329 | markup = templates.matchEdit(match) 330 | @markup = markup 331 | 332 | input = (ev) -> 333 | markup.find("input").asEventStream(ev) 334 | 335 | input("keyup").map(evTarget).onValue ($el) -> 336 | $el.toggleClass "conflict", toIntOrNull($el.val()) is null 337 | 338 | input("change").onValue -> 339 | scoreA = toIntOrNull(markup.find("input.home").val()) 340 | scoreB = toIntOrNull(markup.find("input.away").val()) 341 | return if scoreA is null or scoreB is null 342 | 343 | update = 344 | a: 345 | team: match.a.team 346 | score: scoreA 347 | b: 348 | team: match.b.team 349 | score: scoreB 350 | 351 | resultStream.push update 352 | 353 | drag = (ev) -> 354 | (onval) -> 355 | markup.asEventStream(ev).map(".originalEvent").map(evElTarget).onValues(onval) 356 | 357 | drag("dragstart") (ev, $el) -> 358 | ev.dataTransfer.setData "Text", match.id 359 | $el.css "opacity", 0.5, "" 360 | $container.find(".round").addClass "droppable" 361 | 362 | drag("dragend") (ev, $el) -> 363 | $el.removeAttr "style" 364 | $container.find(".droppable").removeClass "droppable" 365 | 366 | return 367 | )() 368 | 369 | matchStream = new Bacon.Bus() 370 | participantStream = new Bacon.Bus() 371 | renameStream = new Bacon.Bus() 372 | resultStream = new Bacon.Bus() 373 | moveStream = new Bacon.Bus() 374 | removeStream = new Bacon.Bus() 375 | matchProp = matchStream.toProperty( 376 | participants: _([]) 377 | matches: _([]) 378 | ) 379 | 380 | $rounds = templates.rounds.append $(Round.create(moveStream, 0).markup) 381 | $container 382 | .append(templates.standings(participantStream)) 383 | .append(templates.roundsHeader($rounds)) 384 | .append($rounds) 385 | 386 | participantAdds = matchProp.sampledBy(participantStream, (propertyValue, streamValue) -> 387 | if propertyValue.participants.size() > 0 388 | newMatches = propertyValue.participants.map((it) -> 389 | id: generateNewMatchId() 390 | a: 391 | team: it 392 | score: null 393 | b: 394 | team: streamValue 395 | score: null 396 | round: 0 397 | ) 398 | propertyValue.matches = propertyValue.matches.union(newMatches.value()) 399 | 400 | streamValue.label = new Handlebars.SafeString(labeler(streamValue)) 401 | propertyValue.participants.push streamValue 402 | rounds = roundCount(propertyValue.participants.size()) 403 | _(_.range($rounds.find(".round").length - 1, rounds)).each (it) -> 404 | $rounds.append Round.create(moveStream, it + 1).markup 405 | 406 | propertyValue 407 | ) 408 | 409 | participantRemoves = matchProp.sampledBy(removeStream, (propertyValue, streamValue) -> 410 | propertyValue.matches.filter((it) -> 411 | it.a.team.id == streamValue || it.b.team.id == streamValue 412 | ).map((it) -> it.id).forEach (id) -> matchById(id).remove() 413 | 414 | roundsBefore = roundCount(propertyValue.participants.size()) 415 | 416 | propertyValue.participants = propertyValue.participants.filter (it) -> 417 | it.id != streamValue 418 | 419 | roundsAfter = roundCount(propertyValue.participants.size()) 420 | 421 | propertyValue.matches = propertyValue.matches.filter((it) -> 422 | it.a.team.id != streamValue && it.b.team.id != streamValue 423 | ).map((it) -> 424 | if it.round > roundsAfter 425 | it.round = 0 426 | it 427 | ) 428 | 429 | $unassigned = roundById(0) 430 | _(_.range(roundsAfter + 1, roundsBefore + 1)).each (id) -> 431 | $roundToBeDeleted = roundById(id) 432 | $moved = $roundToBeDeleted.find('.match') 433 | $unassigned.append $moved 434 | $roundToBeDeleted.remove() 435 | 436 | propertyValue 437 | ) 438 | 439 | resultUpdates = matchProp.sampledBy(resultStream, (propertyValue, streamValue) -> 440 | propertyValue.matches = propertyValue.matches.map((it) -> 441 | if it.a.team.id is streamValue.a.team.id and it.b.team.id is streamValue.b.team.id 442 | if streamValue.round != undefined 443 | it.round = streamValue.round 444 | it.a.score = streamValue.a.score 445 | it.b.score = streamValue.b.score 446 | else if it.a.team.id is streamValue.b.team.id and it.b.team.id is streamValue.a.team.id 447 | if streamValue.round != undefined 448 | it.round = streamValue.round 449 | it.a.score = streamValue.b.score 450 | it.b.score = streamValue.a.score 451 | it 452 | ) 453 | propertyValue 454 | ) 455 | 456 | participantRenames = matchProp.sampledBy(renameStream, (propertyValue, streamValue) -> 457 | propertyValue.participants = propertyValue.participants.map((it) -> 458 | if it.id == streamValue.id 459 | it.name = streamValue.to 460 | it.label = new Handlebars.SafeString(labeler(it)) 461 | it 462 | ) 463 | propertyValue 464 | ) 465 | 466 | participantMoves = matchProp.sampledBy(moveStream, (propertyValue, streamValue) -> 467 | propertyValue.matches = propertyValue.matches.map((it) -> 468 | it.round = streamValue.round if it.id is streamValue.match 469 | it 470 | ) 471 | propertyValue 472 | ) 473 | 474 | result = Bacon.mergeAll([participantAdds, resultUpdates, participantRenames, participantRemoves, participantMoves]) 475 | 476 | result.throttle(10).onValue (state) -> 477 | onchange(unwrap(state)) if onchange 478 | 479 | participantAdds.merge(resultUpdates).merge(participantRemoves).throttle(10).onValue (state) -> 480 | $container.find(".standings").replaceWith templates.standings(participantStream, 481 | renameStream, removeStream, makeStandings(state.participants, state.matches), null) 482 | 483 | participantRenames.merge(participantAdds).merge(resultUpdates).throttle(10).onValue (state) -> 484 | assignedMatches = state.matches.filter(((it) -> it.round)) 485 | unassignedMatches = state.matches.filter(((it) -> !it.round)) 486 | 487 | assignedMatches.each (it) -> 488 | $match = matchById(it.id) 489 | markup = Match.create(resultStream, it).markup 490 | if $match.length 491 | $match.replaceWith markup 492 | else 493 | roundById(it.round).append markup 494 | 495 | if unassignedMatches.size() > 0 || onchange 496 | $unassigned = roundById(0) 497 | $unassigned.show() 498 | unassignedMatches.each (it) -> 499 | $match = matchById(it.id) 500 | markup = Match.create(resultStream, it).markup 501 | if $match.length 502 | $match.replaceWith markup 503 | else 504 | $unassigned.append(markup) 505 | 506 | participants.each (it) -> 507 | participantStream.push it 508 | 509 | pairs.each (it) -> 510 | resultStream.push it 511 | 512 | methods = init: (opts) -> 513 | opts = opts or {} 514 | labeler = opts.labeler or defaultLabeler 515 | container = this 516 | participants = _() 517 | pairs = _() 518 | 519 | if opts.init 520 | participants = _(opts.init.teams) 521 | pairs = _(opts.init.matches).map (it) -> 522 | it.a.team = opts.init.teams[it.a.team] 523 | it.b.team = opts.init.teams[it.b.team] 524 | it 525 | 526 | initLocalTeamCounter(participants) 527 | 528 | group($('
').appendTo(container), participants, pairs, opts.save or null, labeler) 529 | 530 | $.fn.group = (method) -> 531 | if methods[method] 532 | methods[method].apply this, Array::slice.call(arguments, 1) 533 | else if typeof method is "object" or not method 534 | methods.init.apply this, arguments 535 | else 536 | $.error "Method #{method} does not exist on jQuery.group" 537 | ) jQuery 538 | -------------------------------------------------------------------------------- /src/jquery.group.sass: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery Group 3 | * 4 | * Copyright (c) 2013, Teijo Laine, 5 | * http://aropupu.fi/group/ 6 | * 7 | * Licenced under the MIT licence 8 | */ 9 | 10 | $boxHeight: 22px 11 | $boxPadding: 3px 12 | 13 | .jqgroup.read-write 14 | cursor: default 15 | .match 16 | cursor: move 17 | &:hover 18 | background-color: #CCC 19 | .rounds 20 | .round 21 | padding-bottom: 10px 22 | &:first-child 23 | background-color: #F99 24 | .match 25 | background-color: #FCC 26 | .team 27 | border-color: #FAA 28 | .standings 29 | table 30 | td:first-child 31 | padding: 0 /* In edit mode, td padding is delegated to input element */ 32 | &:hover:before 33 | color: #333 34 | font-size: 1em 35 | content: '\0270E' 36 | position: absolute 37 | left: -1em 38 | .rounds input[type=text].score, .standings input[type=text] 39 | &:focus 40 | text-decoration: none 41 | background-color: #FFF 42 | border: 1px solid black 43 | color: #000 44 | &.conflict, &.add.conflict 45 | background-color: #900 46 | color: #FEE 47 | 48 | .jqgroup 49 | font-family: "Arial" 50 | font-size: 14px 51 | width: 100% 52 | display: inline-block 53 | box-sizing: border-box 54 | input 55 | box-sizing: border-box 56 | height: $boxHeight 57 | .standings 58 | white-space: nowrap 59 | overflow: hidden 60 | table 61 | border-spacing: 0 62 | width: 100% 63 | td, th 64 | padding: $boxPadding 65 | td:nth-child(6) 66 | cursor: help 67 | &:hover 68 | background-color: rgba(255,255,255,0.6) 69 | td:first-child 70 | position: relative 71 | input[type=submit] 72 | width: 100% 73 | input[type=text] 74 | margin: 0 75 | padding: $boxPadding 76 | border: none 77 | width: 100% 78 | background-color: transparent 79 | &.add 80 | border: 1px solid #000 81 | td:not(:first-child) 82 | text-align: center 83 | background-color: rgba(255,255,255,0.3) 84 | th:first-child 85 | text-align: left 86 | td 87 | border-top: 1px solid #DDD 88 | .drop 89 | cursor: pointer 90 | background-color: #FEE 91 | &:hover 92 | background-color: #F00 93 | .rounds 94 | background-color: #DDD 95 | border-top: 1px solid #999 96 | float: left 97 | width: 100% 98 | .roundsHeader 99 | cursor: pointer 100 | &:hover 101 | text-decoration: underline 102 | .unassigned 103 | background-color: #AAA 104 | .participant 105 | background-color: #EEE 106 | .match 107 | &:nth-child(n+3) 108 | div.team 109 | border-top: 1px solid #CCC 110 | &.highlight 111 | border-color: #0B0 112 | background-color: #DDD 113 | min-width: 50px 114 | float: left 115 | clear: both 116 | width: 100% 117 | div.team 118 | float: left 119 | width: 50% 120 | .score, div.label 121 | box-sizing: border-box 122 | padding: $boxPadding 123 | float: left 124 | .score 125 | font-size: 14px 126 | height: $boxHeight 127 | width: 20% 128 | border: none 129 | text-align: center 130 | background-color: rgba(255,255,255,0.3) 131 | &.win 132 | color: #060 133 | &.lose 134 | color: #900 135 | div.label 136 | height: $boxHeight 137 | display: inline-block 138 | white-space: nowrap 139 | overflow: hidden 140 | width: 80% 141 | .round 142 | width: 100% 143 | float: left 144 | &:nth-child(n+3) 145 | margin-top: 10px 146 | clear: both 147 | &.droppable 148 | background-color: #8C8 149 | .match 150 | opacity: 0.7 151 | &.over 152 | background-color: #8F8 153 | [data-roundid="0"] 154 | display: none 155 | header 156 | padding: 0 2px 157 | font-size: 13px 158 | display: block 159 | text-align: center 160 | .highlight 161 | background-color: #3C0 162 | --------------------------------------------------------------------------------