keisanjaku 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/exe/keisanjaku +6 -0
- data/lib/keisanjaku/ansi.rb +79 -0
- data/lib/keisanjaku/app.rb +144 -0
- data/lib/keisanjaku/drill.rb +118 -0
- data/lib/keisanjaku/engine.rb +98 -0
- data/lib/keisanjaku/input.rb +78 -0
- data/lib/keisanjaku/modes/demo.rb +142 -0
- data/lib/keisanjaku/modes/drill.rb +127 -0
- data/lib/keisanjaku/modes/sandbox.rb +159 -0
- data/lib/keisanjaku/parser.rb +185 -0
- data/lib/keisanjaku/planner.rb +306 -0
- data/lib/keisanjaku/procedure.rb +108 -0
- data/lib/keisanjaku/renderer.rb +142 -0
- data/lib/keisanjaku/rule_state.rb +98 -0
- data/lib/keisanjaku/scale.rb +205 -0
- data/lib/keisanjaku/terminal_viewport.rb +107 -0
- data/lib/keisanjaku/version.rb +3 -0
- data/lib/keisanjaku.rb +13 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 58531155e91da64cf07fa9ad169ffee269730111d701b934a278299038881ca2
|
|
4
|
+
data.tar.gz: 027372ea33da151f6fe8b7e2f7565f5d76dd8fa060eb8f17514661f22dad730d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 619c4bbc53e672ca79d717cc148bd134d833361eda0502104e9577c8f2b09a3b21b2b21e56821d354407d95e0b1efda1a5796f093854d0a43db7f520251ae3ea
|
|
7
|
+
data.tar.gz: 7e61d7255ae0af07d6414b1dbdb0a31d1870c02ae207caba7b93305f87bcc2b68293eac7fa9b9d738a7ad0a57a3ebc12e3346c53eebc8878a939548d6f7cfc5a
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# keisanjaku
|
|
2
|
+
|
|
3
|
+
`keisanjaku` is a terminal slide rule simulator. It runs on Ruby's standard
|
|
4
|
+
library only and provides ANSI text rendering, an interactive sandbox, expression
|
|
5
|
+
demos, and drill practice.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Ruby 3.x or later
|
|
10
|
+
- A terminal with at least 100 columns is recommended
|
|
11
|
+
- No external gems are required
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
exe/keisanjaku --version
|
|
17
|
+
exe/keisanjaku --render-only --slide=0.3 --cursor=0.477 --width=100
|
|
18
|
+
exe/keisanjaku --no-anim --demo='2*3'
|
|
19
|
+
exe/keisanjaku --sandbox
|
|
20
|
+
exe/keisanjaku --drill --tolerance=1.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Build and install the gem locally:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
gem build keisanjaku.gemspec
|
|
29
|
+
gem install keisanjaku-0.1.0.gem
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Publish to RubyGems after signing in with `gem signin`:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
gem push keisanjaku-0.1.0.gem
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
In interactive modes, omitting `--width=N` makes the display follow the current
|
|
39
|
+
terminal width. Unix-like terminals use `SIGWINCH`; Windows terminals use a
|
|
40
|
+
polling fallback. Use `NO_COLOR=1` or `--no-color` for plain monochrome output.
|
|
41
|
+
|
|
42
|
+
The expression parser accepts:
|
|
43
|
+
|
|
44
|
+
- Multiplication: `2*3` or `2×3`
|
|
45
|
+
- Division: `6/2` or `6÷2`
|
|
46
|
+
- Powers: `2^2`, `2^3`
|
|
47
|
+
- Functions: `sqrt(2)`, `cbrt(8)`, `log(2)`, `sin(30)`, `cos(60)`, `tan(30)`
|
|
48
|
+
|
|
49
|
+
## Example Output
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
+--------------------------------------------------------------------------------------------+
|
|
53
|
+
K|| | | | || || | | | | | | | | | || | | | | | || || | | ||
|
|
54
|
+
|1 2 3 4 56 78 9 1 2 3 4 5 6 7 891 2 3 4 5 6 78 9 1 |
|
|
55
|
+
A|| | | | | | | | | | | | | | | | | | | | ||
|
|
56
|
+
|1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 |
|
|
57
|
+
+--------------------------------------------------------------------------------------------+
|
|
58
|
+
B|........................|.............|........|.....|....|...|..|..|.|.....................|
|
|
59
|
+
|........................1.............2........3.....4....5...6..7..8.9.....................|
|
|
60
|
+
CI|........................|...|..:..|.:.|..:..|...|....:....|.....:......|...........:........|
|
|
61
|
+
|........................1...9.....8...7.....6...5.........4............3....................|
|
|
62
|
+
C|........................|....................|............:........|.......:.....|....:...|.|
|
|
63
|
+
|........................1....................2....................3.............4............|
|
|
64
|
+
+--------------------------------------------------------------------------------------------+
|
|
65
|
+
D|| : | : | : | : | : | : | : | : | : | : ||
|
|
66
|
+
|1 2 3 4 5 6 7 8 9 1|
|
|
67
|
+
L|| | | | | | | | | | ||
|
|
68
|
+
|0 .1 .2 .3 .4 .5 .6 .7 .8 .9 1|
|
|
69
|
+
+--------------------------------------------------------------------------------------------+
|
|
70
|
+
cursor=0.4770 slide=+0.3000 face=front K=26.977 A=8.995 B=2.2594 CI=6.6527 C=1.5 D=2.9997 L=0.477
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Controls
|
|
74
|
+
|
|
75
|
+
Sandbox:
|
|
76
|
+
|
|
77
|
+
- `h` / `l`: move the slide left or right in coarse steps
|
|
78
|
+
- `H` / `L`, or `,` / `.`: move the slide in fine steps
|
|
79
|
+
- `c` / `j` / `k`: switch arrow-key control between the cursor and slide
|
|
80
|
+
- `Left` / `Right`: move the selected target
|
|
81
|
+
- `Tab`: flip between the front and back slide faces
|
|
82
|
+
- `?`: toggle the value readout
|
|
83
|
+
- `r`: reset
|
|
84
|
+
- `q`: quit
|
|
85
|
+
|
|
86
|
+
Drill:
|
|
87
|
+
|
|
88
|
+
- Choose the problem range at startup, for example `multiply,sin`
|
|
89
|
+
- Press `Enter` or `o` for the slide-rule workspace and answer entry
|
|
90
|
+
- Type a numeric answer in the workspace and press `Enter` to submit it
|
|
91
|
+
- `g`: show the worked demo
|
|
92
|
+
- `s`: skip the workspace and answer as plain text
|
|
93
|
+
- Correct answers report whether the exact operation sequence matched the model
|
|
94
|
+
steps, including scale, value, and side information such as `MOVE_CURSOR(D, 2)`
|
|
95
|
+
|
|
96
|
+
Demo:
|
|
97
|
+
|
|
98
|
+
- `Enter` / `Space`: advance one step
|
|
99
|
+
- `b`: go back one step
|
|
100
|
+
- `a`: toggle autoplay
|
|
101
|
+
- `1` / `2` / `3`: change autoplay speed
|
|
102
|
+
- `q`: quit
|
|
103
|
+
|
|
104
|
+
## Principle
|
|
105
|
+
|
|
106
|
+
The C and D scales are modeled with positions based on `log10(x)`.
|
|
107
|
+
Multiplication is addition of distances: align the C scale index with a value on
|
|
108
|
+
the D scale, move the cursor to the other factor on the C scale, and read the
|
|
109
|
+
mantissa of the product on the D scale.
|
|
110
|
+
|
|
111
|
+
When the result would fall outside the frame, use the opposite slide index to
|
|
112
|
+
wrap the operation. A slide rule reads mantissas, so the final power-of-ten place
|
|
113
|
+
must be determined separately. Demo mode prints that place-value explanation at
|
|
114
|
+
the end.
|
|
115
|
+
|
|
116
|
+
## Tests
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bundle install
|
|
120
|
+
rake test
|
|
121
|
+
ruby -w -Ilib -rkeisanjaku -e 'puts Keisanjaku::VERSION'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/exe/keisanjaku
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Keisanjaku
|
|
2
|
+
module ANSI
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
CODES = {
|
|
6
|
+
reset: 0,
|
|
7
|
+
red: 31,
|
|
8
|
+
yellow: 33,
|
|
9
|
+
cyan_bg: 46,
|
|
10
|
+
bold: 1
|
|
11
|
+
}.freeze
|
|
12
|
+
AMBIGUOUS_WIDE_CHARS = "×÷±−√°·".chars.freeze
|
|
13
|
+
|
|
14
|
+
def enabled?(color)
|
|
15
|
+
color && ENV["NO_COLOR"].nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def paint(text, *styles, color: true)
|
|
19
|
+
return text unless enabled?(color)
|
|
20
|
+
|
|
21
|
+
codes = styles.map { |style| CODES.fetch(style) }.join(";")
|
|
22
|
+
"\e[#{codes}m#{text}\e[0m"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clear_screen
|
|
26
|
+
"\e[2J\e[H"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def hide_cursor
|
|
30
|
+
"\e[?25l"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def show_cursor
|
|
34
|
+
"\e[?25h"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def strip(text)
|
|
38
|
+
text.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visible_width(text)
|
|
42
|
+
strip(text).each_char.sum { |char| wide?(char) ? 2 : 1 }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fit(text, width)
|
|
46
|
+
stripped_width = visible_width(text)
|
|
47
|
+
return text + (" " * (width - stripped_width)) if stripped_width < width
|
|
48
|
+
return text if stripped_width == width
|
|
49
|
+
|
|
50
|
+
truncate(text, width)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def truncate(text, width)
|
|
54
|
+
output = +""
|
|
55
|
+
used = 0
|
|
56
|
+
strip(text).each_char do |char|
|
|
57
|
+
char_width = wide?(char) ? 2 : 1
|
|
58
|
+
break if used + char_width > width
|
|
59
|
+
|
|
60
|
+
output << char
|
|
61
|
+
used += char_width
|
|
62
|
+
end
|
|
63
|
+
output << (" " * (width - used))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def wide?(char)
|
|
67
|
+
code = char.ord
|
|
68
|
+
(0x1100..0x115F).cover?(code) ||
|
|
69
|
+
(0x2E80..0xA4CF).cover?(code) ||
|
|
70
|
+
(0xAC00..0xD7A3).cover?(code) ||
|
|
71
|
+
(0xF900..0xFAFF).cover?(code) ||
|
|
72
|
+
(0xFE10..0xFE19).cover?(code) ||
|
|
73
|
+
(0xFE30..0xFE6F).cover?(code) ||
|
|
74
|
+
(0xFF00..0xFF60).cover?(code) ||
|
|
75
|
+
(0xFFE0..0xFFE6).cover?(code) ||
|
|
76
|
+
AMBIGUOUS_WIDE_CHARS.include?(char)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "io/console"
|
|
3
|
+
require_relative "version"
|
|
4
|
+
require_relative "renderer"
|
|
5
|
+
require_relative "parser"
|
|
6
|
+
require_relative "planner"
|
|
7
|
+
require_relative "engine"
|
|
8
|
+
require_relative "terminal_viewport"
|
|
9
|
+
require_relative "modes/sandbox"
|
|
10
|
+
require_relative "modes/demo"
|
|
11
|
+
require_relative "modes/drill"
|
|
12
|
+
|
|
13
|
+
module Keisanjaku
|
|
14
|
+
class App
|
|
15
|
+
def initialize(argv, input: $stdin, output: $stdout, error: $stderr)
|
|
16
|
+
@argv = argv.dup
|
|
17
|
+
@input = input
|
|
18
|
+
@output = output
|
|
19
|
+
@error = error
|
|
20
|
+
@options = {
|
|
21
|
+
color: ENV["NO_COLOR"].nil?,
|
|
22
|
+
animate: true,
|
|
23
|
+
width: nil,
|
|
24
|
+
tolerance: 1.0,
|
|
25
|
+
slide: 0.0,
|
|
26
|
+
cursor: 0.0,
|
|
27
|
+
face: :front
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
parse_options
|
|
33
|
+
return print_version if @options[:version]
|
|
34
|
+
return render_only if @options[:render_only]
|
|
35
|
+
return key_test if @options[:key_test]
|
|
36
|
+
return run_demo(@options[:demo]) if @options[:demo]
|
|
37
|
+
return run_drill if @options[:drill]
|
|
38
|
+
return run_sandbox if @options[:sandbox]
|
|
39
|
+
|
|
40
|
+
expression = @argv.join(" ").strip
|
|
41
|
+
return run_demo(expression) unless expression.empty?
|
|
42
|
+
|
|
43
|
+
run_menu
|
|
44
|
+
rescue ParseError, ScaleError, OptionParser::ParseError => e
|
|
45
|
+
@error.puts "keisanjaku: #{e.message}"
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_options
|
|
52
|
+
parser = OptionParser.new do |opts|
|
|
53
|
+
opts.banner = "Usage: keisanjaku [options] [expression]"
|
|
54
|
+
opts.on("--version", "Print version") { @options[:version] = true }
|
|
55
|
+
opts.on("--no-color", "Disable ANSI colors") { @options[:color] = false }
|
|
56
|
+
opts.on("--no-anim", "Disable animation") { @options[:animate] = false }
|
|
57
|
+
opts.on("--width=N", Integer, "Render width") { |value| @options[:width] = value }
|
|
58
|
+
opts.on("--tolerance=X", Float, "Drill tolerance percent") { |value| @options[:tolerance] = value }
|
|
59
|
+
opts.on("--render-only", "Render one frame and exit") { @options[:render_only] = true }
|
|
60
|
+
opts.on("--slide=X", Float, "Initial slide offset") { |value| @options[:slide] = value }
|
|
61
|
+
opts.on("--cursor=X", Float, "Initial cursor position") { |value| @options[:cursor] = value }
|
|
62
|
+
opts.on("--face=FACE", "front or back") { |value| @options[:face] = value.to_sym }
|
|
63
|
+
opts.on("--key-test", "Print decoded key names") { @options[:key_test] = true }
|
|
64
|
+
opts.on("--demo=EXPR", "Run demo for expression") { |value| @options[:demo] = value }
|
|
65
|
+
opts.on("--sandbox", "Start sandbox mode") { @options[:sandbox] = true }
|
|
66
|
+
opts.on("--drill", "Start drill mode") { @options[:drill] = true }
|
|
67
|
+
end
|
|
68
|
+
parser.parse!(@argv)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print_version
|
|
72
|
+
@output.puts "keisanjaku #{VERSION}"
|
|
73
|
+
0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_only
|
|
77
|
+
state = RuleState.new(slide: @options[:slide], cursor: @options[:cursor], face: @options[:face])
|
|
78
|
+
Renderer.render(state, width: @options[:width] || terminal_width, color: @options[:color]).each { |line| @output.puts line }
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def key_test
|
|
83
|
+
reader = Input.new(@input)
|
|
84
|
+
Input.with_raw(input: @input, output: @output) do
|
|
85
|
+
loop do
|
|
86
|
+
key = reader.read_key
|
|
87
|
+
Input.raw_puts(@output, key.inspect)
|
|
88
|
+
break if key == "q" || key == :ctrl_c
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_demo(expression)
|
|
95
|
+
mode = Modes::Demo.new(expression, width: @options[:width], color: @options[:color], animate: @options[:animate], output: @output)
|
|
96
|
+
if @input.tty? && @options[:animate]
|
|
97
|
+
mode.run_interactive(input: @input)
|
|
98
|
+
else
|
|
99
|
+
mode.run_noninteractive
|
|
100
|
+
end
|
|
101
|
+
0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_sandbox
|
|
105
|
+
Modes::Sandbox.new(width: @options[:width], color: @options[:color], output: @output).run(input: @input)
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run_drill
|
|
110
|
+
Modes::Drill.new(
|
|
111
|
+
width: @options[:width],
|
|
112
|
+
color: @options[:color],
|
|
113
|
+
tolerance_percent: @options[:tolerance],
|
|
114
|
+
output: @output
|
|
115
|
+
).run(input: @input)
|
|
116
|
+
0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_menu
|
|
120
|
+
return run_sandbox unless @input.tty?
|
|
121
|
+
|
|
122
|
+
@output.puts "keisanjaku"
|
|
123
|
+
@output.puts "1 Sandbox"
|
|
124
|
+
@output.puts "2 Demo"
|
|
125
|
+
@output.puts "3 Drill"
|
|
126
|
+
@output.print "> "
|
|
127
|
+
choice = @input.gets&.strip
|
|
128
|
+
case choice
|
|
129
|
+
when "1" then run_sandbox
|
|
130
|
+
when "2"
|
|
131
|
+
@output.print "expr> "
|
|
132
|
+
run_demo(@input.gets&.strip.to_s)
|
|
133
|
+
when "3" then run_drill
|
|
134
|
+
else 0
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def terminal_width
|
|
139
|
+
IO.console&.winsize&.last || Renderer::DEFAULT_WIDTH
|
|
140
|
+
rescue StandardError
|
|
141
|
+
Renderer::DEFAULT_WIDTH
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require_relative "ansi"
|
|
2
|
+
require_relative "parser"
|
|
3
|
+
require_relative "planner"
|
|
4
|
+
require_relative "procedure"
|
|
5
|
+
|
|
6
|
+
module Keisanjaku
|
|
7
|
+
Question = Struct.new(:expression, :prompt, :true_value, :operation, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
class QuestionGenerator
|
|
10
|
+
OPERATIONS = %i[multiply divide square cube sqrt log sin tan].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(random: Random.new)
|
|
13
|
+
@random = random
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def next_question(operation: OPERATIONS.sample(random: @random))
|
|
17
|
+
operation = operation.to_sym
|
|
18
|
+
raise ArgumentError, "unknown operation: #{operation}" unless OPERATIONS.include?(operation)
|
|
19
|
+
|
|
20
|
+
case operation
|
|
21
|
+
when :multiply
|
|
22
|
+
a = sample_mantissa
|
|
23
|
+
b = sample_mantissa
|
|
24
|
+
build("#{a}×#{b}", "#{a} × #{b} を求めよ", operation)
|
|
25
|
+
when :divide
|
|
26
|
+
a = sample_mantissa
|
|
27
|
+
b = sample_mantissa
|
|
28
|
+
build("#{a}÷#{b}", "#{a} ÷ #{b} を求めよ", operation)
|
|
29
|
+
when :square
|
|
30
|
+
a = sample_mantissa(max: 9.0)
|
|
31
|
+
build("#{a}^2", "#{a} の二乗を求めよ", operation)
|
|
32
|
+
when :cube
|
|
33
|
+
a = sample_mantissa(max: 9.0)
|
|
34
|
+
build("#{a}^3", "#{a} の三乗を求めよ", operation)
|
|
35
|
+
when :sqrt
|
|
36
|
+
a = sample_mantissa(max: 9.0)
|
|
37
|
+
build("sqrt(#{a})", "#{a} の平方根を求めよ", operation)
|
|
38
|
+
when :log
|
|
39
|
+
a = sample_mantissa
|
|
40
|
+
build("log(#{a})", "#{a} の常用対数を求めよ", operation)
|
|
41
|
+
when :sin
|
|
42
|
+
angle = @random.rand(6..89)
|
|
43
|
+
build("sin(#{angle})", "sin(#{angle}) を求めよ", operation)
|
|
44
|
+
when :tan
|
|
45
|
+
angle = @random.rand(6..45)
|
|
46
|
+
build("tan(#{angle})", "tan(#{angle}) を求めよ", operation)
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "unknown operation: #{operation}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build(expression, prompt, operation)
|
|
55
|
+
ast = Parser.parse(expression)
|
|
56
|
+
plan = Planner.compile(ast, expression: expression)
|
|
57
|
+
Question.new(expression: expression, prompt: prompt, true_value: plan.true_value, operation: operation)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def sample_mantissa(max: 9.9)
|
|
61
|
+
(@random.rand(10..(max * 10).floor) / 10.0).round(1)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Judge
|
|
66
|
+
def initialize(tolerance_percent: 1.0)
|
|
67
|
+
@tolerance_percent = Float(tolerance_percent)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def correct?(answer, true_value)
|
|
71
|
+
relative_error_percent(answer, true_value).abs <= @tolerance_percent + 1e-12
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative_error_percent(answer, true_value)
|
|
75
|
+
((Float(answer) - Float(true_value)) / Float(true_value)) * 100.0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class OperationSelector
|
|
80
|
+
LABELS = {
|
|
81
|
+
multiply: "乗算",
|
|
82
|
+
divide: "除算",
|
|
83
|
+
square: "二乗",
|
|
84
|
+
cube: "三乗",
|
|
85
|
+
sqrt: "平方根",
|
|
86
|
+
log: "対数",
|
|
87
|
+
sin: "正弦",
|
|
88
|
+
tan: "正接"
|
|
89
|
+
}.freeze
|
|
90
|
+
|
|
91
|
+
def self.parse(selection)
|
|
92
|
+
text = selection.to_s.strip
|
|
93
|
+
return QuestionGenerator::OPERATIONS if text.empty? || text == "all"
|
|
94
|
+
|
|
95
|
+
operations = text.split(/[,\s]+/).filter_map do |token|
|
|
96
|
+
operation = token.to_sym
|
|
97
|
+
operation if QuestionGenerator::OPERATIONS.include?(operation)
|
|
98
|
+
end
|
|
99
|
+
operations.empty? ? QuestionGenerator::OPERATIONS : operations.uniq
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.menu
|
|
103
|
+
LABELS.map { |key, label| "#{key}=#{label}" }.join(" ")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.menu_lines(width)
|
|
107
|
+
LABELS.map { |key, label| "#{key}=#{label}" }.each_with_object([]) do |item, lines|
|
|
108
|
+
candidate = lines.empty? || lines.last.empty? ? item : "#{lines.last} #{item}"
|
|
109
|
+
if lines.empty? || ANSI.visible_width(candidate) > width
|
|
110
|
+
lines << item
|
|
111
|
+
else
|
|
112
|
+
lines[-1] = candidate
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require_relative "planner"
|
|
2
|
+
|
|
3
|
+
module Keisanjaku
|
|
4
|
+
class Engine
|
|
5
|
+
attr_reader :plan, :index
|
|
6
|
+
|
|
7
|
+
def initialize(plan, initial_state: RuleState.new, animate: true)
|
|
8
|
+
@plan = plan
|
|
9
|
+
@initial_state = initial_state.dup
|
|
10
|
+
@history = [@initial_state.dup]
|
|
11
|
+
@index = 0
|
|
12
|
+
@animate = animate
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def state
|
|
16
|
+
@history[@index]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def done?
|
|
20
|
+
@index >= plan.steps.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def step_forward
|
|
24
|
+
return nil if done?
|
|
25
|
+
|
|
26
|
+
next_state = state.dup
|
|
27
|
+
step = plan.steps[@index]
|
|
28
|
+
self.class.apply_step(next_state, step)
|
|
29
|
+
@history = @history.take(@index + 1)
|
|
30
|
+
@history << next_state
|
|
31
|
+
@index += 1
|
|
32
|
+
step
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def step_back
|
|
36
|
+
return nil if @index.zero?
|
|
37
|
+
|
|
38
|
+
@index -= 1
|
|
39
|
+
plan.steps[@index]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_to_end
|
|
43
|
+
step_forward until done?
|
|
44
|
+
state
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def frames_for(step, from:, to:, count: 8)
|
|
48
|
+
return [to] unless @animate && movement_step?(step)
|
|
49
|
+
|
|
50
|
+
(1..count).map do |n|
|
|
51
|
+
ratio = n.to_f / count
|
|
52
|
+
RuleState.new(
|
|
53
|
+
slide: from.slide + (to.slide - from.slide) * ratio,
|
|
54
|
+
cursor: from.cursor + (to.cursor - from.cursor) * ratio,
|
|
55
|
+
face: to.face
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def summary
|
|
61
|
+
error = if plan.true_value.zero?
|
|
62
|
+
0.0
|
|
63
|
+
else
|
|
64
|
+
((plan.read_value - plan.true_value) / plan.true_value * 100.0).abs
|
|
65
|
+
end
|
|
66
|
+
{
|
|
67
|
+
read_value: plan.read_value,
|
|
68
|
+
true_value: plan.true_value,
|
|
69
|
+
error_percent: error,
|
|
70
|
+
place_explanation: plan.place_explanation
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.apply_step(state, step)
|
|
75
|
+
case step.primitive
|
|
76
|
+
when :move_cursor
|
|
77
|
+
state.move_cursor_to(step.scale, step.value)
|
|
78
|
+
when :move_slide_index
|
|
79
|
+
state.align_slide_index(step.side)
|
|
80
|
+
when :move_slide_to
|
|
81
|
+
state.align_slide_value(step.scale, step.value)
|
|
82
|
+
when :flip_slide
|
|
83
|
+
state.flip!
|
|
84
|
+
when :read
|
|
85
|
+
state
|
|
86
|
+
else
|
|
87
|
+
raise ArgumentError, "unknown step primitive: #{step.primitive}"
|
|
88
|
+
end
|
|
89
|
+
state
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def movement_step?(step)
|
|
95
|
+
%i[move_cursor move_slide_index move_slide_to flip_slide].include?(step.primitive)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require_relative "ansi"
|
|
3
|
+
|
|
4
|
+
module Keisanjaku
|
|
5
|
+
class Input
|
|
6
|
+
CRLF = "\r\n"
|
|
7
|
+
|
|
8
|
+
CSI_MAP = {
|
|
9
|
+
"\e[A" => :up,
|
|
10
|
+
"\e[B" => :down,
|
|
11
|
+
"\e[C" => :right,
|
|
12
|
+
"\e[D" => :left,
|
|
13
|
+
"\e[H" => :home,
|
|
14
|
+
"\e[F" => :end,
|
|
15
|
+
"\e[3~" => :delete
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
SIMPLE_MAP = {
|
|
19
|
+
"\r" => :enter,
|
|
20
|
+
"\n" => :enter,
|
|
21
|
+
"\t" => :tab,
|
|
22
|
+
"\u007F" => :backspace,
|
|
23
|
+
"\e" => :escape,
|
|
24
|
+
" " => :space
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(io = $stdin)
|
|
28
|
+
@io = io
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read_key(timeout: nil)
|
|
32
|
+
return nil if timeout && !@io.wait_readable(timeout)
|
|
33
|
+
|
|
34
|
+
first = @io.getch
|
|
35
|
+
return nil unless first
|
|
36
|
+
return decode_sequence(first) unless first == "\e"
|
|
37
|
+
|
|
38
|
+
sequence = first.dup
|
|
39
|
+
while @io.wait_readable(0.01)
|
|
40
|
+
sequence << @io.getch
|
|
41
|
+
break if CSI_MAP.key?(sequence) || sequence.match?(/\A\e\[[0-9;]*[~A-Za-z]\z/)
|
|
42
|
+
end
|
|
43
|
+
decode_sequence(sequence)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.decode_sequence(sequence)
|
|
47
|
+
return CSI_MAP[sequence] if CSI_MAP.key?(sequence)
|
|
48
|
+
return SIMPLE_MAP[sequence] if SIMPLE_MAP.key?(sequence)
|
|
49
|
+
return :ctrl_c if sequence == "\u0003"
|
|
50
|
+
|
|
51
|
+
sequence
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def decode_sequence(sequence)
|
|
55
|
+
self.class.decode_sequence(sequence)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.with_raw(input: $stdin, output: $stdout, clear_on_exit: true)
|
|
59
|
+
output.print ANSI.hide_cursor
|
|
60
|
+
input.raw do
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
output.print ANSI.clear_screen if clear_on_exit
|
|
64
|
+
output.print ANSI.show_cursor
|
|
65
|
+
output.flush
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.raw_puts(output, value = "")
|
|
70
|
+
output.print value.to_s
|
|
71
|
+
output.print CRLF
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.raw_print_lines(output, lines)
|
|
75
|
+
lines.each { |line| raw_puts(output, line) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|